@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.
Files changed (151) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/payload.txt +22 -0
  3. package/LICENSES/ponytail.txt +21 -0
  4. package/dist/asas/actor.d.ts +96 -0
  5. package/dist/asas/actor.js +114 -0
  6. package/dist/asas/build.d.ts +231 -0
  7. package/dist/asas/build.js +787 -0
  8. package/dist/asas/cli/mithaq.d.ts +2 -0
  9. package/dist/asas/cli/mithaq.js +145 -0
  10. package/dist/asas/cli/upgrade.d.ts +20 -0
  11. package/dist/asas/cli/upgrade.js +147 -0
  12. package/dist/asas/http/fetch.d.ts +23 -0
  13. package/dist/asas/http/fetch.js +115 -0
  14. package/dist/asas/http/orpc.d.ts +43 -0
  15. package/dist/asas/http/orpc.js +135 -0
  16. package/dist/asas/runner/bulk.d.ts +20 -0
  17. package/dist/asas/runner/bulk.js +191 -0
  18. package/dist/asas/runner/errors.d.ts +60 -0
  19. package/dist/asas/runner/errors.js +59 -0
  20. package/dist/asas/runner/read.d.ts +68 -0
  21. package/dist/asas/runner/read.js +579 -0
  22. package/dist/asas/runner/runtime.d.ts +21 -0
  23. package/dist/asas/runner/runtime.js +8 -0
  24. package/dist/asas/runner/steps.d.ts +400 -0
  25. package/dist/asas/runner/steps.js +271 -0
  26. package/dist/asas/runner/tenant.d.ts +54 -0
  27. package/dist/asas/runner/tenant.js +213 -0
  28. package/dist/asas/runner/write.d.ts +444 -0
  29. package/dist/asas/runner/write.js +791 -0
  30. package/dist/auth/better-auth/access.d.ts +15 -0
  31. package/dist/auth/better-auth/access.js +71 -0
  32. package/dist/auth/better-auth/generate.d.ts +29 -0
  33. package/dist/auth/better-auth/generate.js +310 -0
  34. package/dist/auth/better-auth/index.d.ts +92 -0
  35. package/dist/auth/better-auth/index.js +81 -0
  36. package/dist/auth/better-auth/tenant.d.ts +18 -0
  37. package/dist/auth/better-auth/tenant.js +26 -0
  38. package/dist/auth/local/index.d.ts +285 -0
  39. package/dist/auth/local/index.js +736 -0
  40. package/dist/base/decimal.d.ts +9 -0
  41. package/dist/base/decimal.js +16 -0
  42. package/dist/base/fields.d.ts +123 -0
  43. package/dist/base/fields.js +51 -0
  44. package/dist/base/index.d.ts +46 -0
  45. package/dist/base/index.js +5 -0
  46. package/dist/base/kinds.d.ts +54 -0
  47. package/dist/base/kinds.js +78 -0
  48. package/dist/base/rules.d.ts +73 -0
  49. package/dist/base/rules.js +80 -0
  50. package/dist/drizzle/index.d.ts +438 -0
  51. package/dist/drizzle/index.js +832 -0
  52. package/dist/drizzle/kinds.d.ts +27 -0
  53. package/dist/drizzle/kinds.js +41 -0
  54. package/dist/drizzle/migrate.d.ts +133 -0
  55. package/dist/drizzle/migrate.js +1285 -0
  56. package/dist/drizzle/proxy/index.d.ts +1 -0
  57. package/dist/drizzle/proxy/index.js +1 -0
  58. package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
  59. package/dist/drizzle/proxy/node-postgres.js +1 -0
  60. package/dist/drizzle/proxy/pg-core.d.ts +1 -0
  61. package/dist/drizzle/proxy/pg-core.js +1 -0
  62. package/dist/drizzle/rules.d.ts +336 -0
  63. package/dist/drizzle/rules.js +44 -0
  64. package/dist/index.d.ts +45 -0
  65. package/dist/index.js +36 -0
  66. package/dist/mithaq/define.d.ts +288 -0
  67. package/dist/mithaq/define.js +43 -0
  68. package/dist/mithaq/generate-messages.d.ts +38 -0
  69. package/dist/mithaq/generate-messages.js +234 -0
  70. package/dist/mithaq/language.d.ts +335 -0
  71. package/dist/mithaq/language.js +76 -0
  72. package/dist/mithaq/lock.d.ts +71 -0
  73. package/dist/mithaq/lock.js +329 -0
  74. package/dist/mithaq/messages.d.ts +55 -0
  75. package/dist/mithaq/messages.js +106 -0
  76. package/dist/mithaq/refusal.d.ts +6 -0
  77. package/dist/mithaq/refusal.js +6 -0
  78. package/dist/mithaq/resolve.d.ts +10 -0
  79. package/dist/mithaq/resolve.js +954 -0
  80. package/dist/mithaq/rows.d.ts +87 -0
  81. package/dist/mithaq/rows.js +174 -0
  82. package/dist/mithaq/rukn.d.ts +72 -0
  83. package/dist/mithaq/rukn.js +15 -0
  84. package/dist/mithaq/standard-schema.d.ts +38 -0
  85. package/dist/mithaq/standard-schema.js +2 -0
  86. package/dist/mithaq/types.d.ts +358 -0
  87. package/dist/mithaq/types.js +15 -0
  88. package/dist/mithaq/user.d.ts +19 -0
  89. package/dist/mithaq/user.js +16 -0
  90. package/dist/mithaq/where.d.ts +32 -0
  91. package/dist/mithaq/where.js +87 -0
  92. package/dist/rbac/index.d.ts +105 -0
  93. package/dist/rbac/index.js +182 -0
  94. package/dist/rbac/steps.d.ts +22 -0
  95. package/dist/rbac/steps.js +161 -0
  96. package/dist/rich-text/index.d.ts +27 -0
  97. package/dist/rich-text/index.js +58 -0
  98. package/dist/rich-text/rich-text.d.ts +159 -0
  99. package/dist/rich-text/rich-text.js +372 -0
  100. package/dist/screen/client.d.ts +153 -0
  101. package/dist/screen/client.js +68 -0
  102. package/dist/screen/generate.d.ts +14 -0
  103. package/dist/screen/generate.js +226 -0
  104. package/dist/screen/index.d.ts +479 -0
  105. package/dist/screen/index.js +645 -0
  106. package/dist/screen/react.d.ts +95 -0
  107. package/dist/screen/react.js +58 -0
  108. package/dist/screen/studio.d.ts +24 -0
  109. package/dist/screen/studio.js +68 -0
  110. package/dist/service/index.d.ts +63 -0
  111. package/dist/service/index.js +15 -0
  112. package/dist/storage/allowed-type.d.ts +5 -0
  113. package/dist/storage/allowed-type.js +7 -0
  114. package/dist/storage/index.d.ts +43 -0
  115. package/dist/storage/index.js +44 -0
  116. package/dist/storage/storage.d.ts +384 -0
  117. package/dist/storage/storage.js +405 -0
  118. package/dist/versions/index.d.ts +37 -0
  119. package/dist/versions/index.js +8 -0
  120. package/dist/versions/versions.d.ts +104 -0
  121. package/dist/versions/versions.js +160 -0
  122. package/dist/zod/index.d.ts +184 -0
  123. package/dist/zod/index.js +121 -0
  124. package/dist/zod/kinds.d.ts +34 -0
  125. package/dist/zod/kinds.js +49 -0
  126. package/dist/zod/rules.d.ts +117 -0
  127. package/dist/zod/rules.js +67 -0
  128. package/package.json +63 -4
  129. package/r/mithaq-cells.json +20 -0
  130. package/r/mithaq-controls.json +37 -0
  131. package/r/mithaq-directional-icon.json +15 -0
  132. package/r/mithaq-expected-updated-at.json +15 -0
  133. package/r/mithaq-field-shell.json +18 -0
  134. package/r/mithaq-filter-operators.json +15 -0
  135. package/r/mithaq-form.json +33 -0
  136. package/r/mithaq-list-columns.json +23 -0
  137. package/r/mithaq-list-filters.json +24 -0
  138. package/r/mithaq-list-pagination.json +24 -0
  139. package/r/mithaq-list-query.json +18 -0
  140. package/r/mithaq-list-selection.json +19 -0
  141. package/r/mithaq-list.json +33 -0
  142. package/r/mithaq-nav.json +24 -0
  143. package/r/mithaq-nodes.json +34 -0
  144. package/r/mithaq-relationship-cell.json +20 -0
  145. package/r/mithaq-relationship-control.json +20 -0
  146. package/r/mithaq-rich-text.json +40 -0
  147. package/r/mithaq-screens-host.json +96 -0
  148. package/r/mithaq-storage.json +44 -0
  149. package/r/mithaq-upload.json +29 -0
  150. package/r/registry.json +550 -0
  151. package/README.md +0 -3
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "mithaq-list-columns",
4
+ "title": "Mithaq list columns",
5
+ "description": "The column popup: two sections, a switch per column and two buttons for order, with a denied or undeclared column absent rather than disabled.",
6
+ "registryDependencies": [
7
+ "button",
8
+ "checkbox",
9
+ "label",
10
+ "popover",
11
+ "@mithaq/mithaq-controls",
12
+ "@mithaq/mithaq-list-query"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "registry/list-columns.tsx",
17
+ "content": "import { useState, type ReactElement } from 'react'\nimport { ChevronDown, Columns as LayoutColumns } from 'lucide-react'\nimport type { ClientField, ClientKayan } from 'mithaq/admin'\nimport { Button } from '@/components/ui/button'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Label } from '@/components/ui/label'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { checkboxSize, labelFor, type Translate } from './controls.tsx'\nimport type { ColumnChoice } from './list-query.ts'\n\nexport type ColumnsProps = {\n kayan: ClientKayan\n /** Every column, in order, with whether it is shown. */\n columns: readonly ColumnChoice[]\n onChange: (columns: readonly ColumnChoice[]) => void\n t: Translate\n locale: string\n}\n\n/** Which fields the popup may offer: the ones this actor may read. Chrome only: the read masks a denied column whatever the popup shows. */\nexport const columnCandidates = (\n kayan: ClientKayan,\n reads?: (kayan: string, field: string) => boolean,\n): readonly ClientField[] =>\n kayan.fields.filter(\n (field) => !reads || reads(kayan.name, field.name),\n )\n\n/** The next column in the same section, or -1 at its end. The popup draws one list twice, so a neighbour never crosses into the other section. */\nexport const neighbour = ({ columns, at, by }: { columns: readonly ColumnChoice[]; at: number; by: -1 | 1 }): number => {\n for (let index = at + by; index >= 0 && index < columns.length; index += by) {\n if (columns[index]!.active === columns[at]!.active) return index\n }\n return -1\n}\n\n/** One column swapped with its neighbour in the same section; at a section's end the same list comes back. */\nexport const move = ({ columns, at, by }: { columns: readonly ColumnChoice[]; at: number; by: -1 | 1 }): readonly ColumnChoice[] => {\n const to = neighbour({ columns, at, by })\n if (to < 0) return columns\n const next = [...columns]\n next[at] = columns[to]!\n next[to] = columns[at]!\n return next\n}\n\nexport function Columns({ columns, kayan, locale, onChange, t }: ColumnsProps): ReactElement {\n const [open, setOpen] = useState(false)\n const byName = new Map(kayan.fields.map((field) => [field.name, field]))\n const nameOf = (name: string) => {\n const field = byName.get(name)\n return field ? labelFor({ kayan, field, copy: { t, locale } }) : name\n }\n const toggle = (name: string) =>\n onChange(columns.map((one) => (one.name === name ? { ...one, active: !one.active } : one)))\n // The box is a `role=\"checkbox\"` button, so the label points at it by id; the kayan name keeps ids unique when two lists share a screen.\n const boxId = (name: string) => `mithaq-column-${kayan.name}-${name}`\n const section = (active: boolean) => (\n <ul className=\"flex flex-col gap-1\">\n {columns.map((column, at) =>\n column.active !== active ? null : (\n <li key={column.name} className=\"flex items-center justify-between gap-2\">\n <span className=\"flex items-center gap-2\">\n <Checkbox className={checkboxSize} id={boxId(column.name)} checked={column.active} onCheckedChange={() => toggle(column.name)} />\n <Label htmlFor={boxId(column.name)} className=\"font-normal\">\n {nameOf(column.name)}\n </Label>\n </span>\n <span className=\"flex gap-1\">\n <Button type=\"button\" variant=\"outline\" size=\"icon-xs\" className=\"max-sm:size-11\" disabled={neighbour({ columns, at, by: -1 }) < 0} onClick={() => onChange(move({ columns, at, by: -1 }))} aria-label={`${t('columns.up', 'Move up') ?? 'Move up'}: ${nameOf(column.name)}`}>\n ↑\n </Button>\n <Button type=\"button\" variant=\"outline\" size=\"icon-xs\" className=\"max-sm:size-11\" disabled={neighbour({ columns, at, by: 1 }) < 0} onClick={() => onChange(move({ columns, at, by: 1 }))} aria-label={`${t('columns.down', 'Move down') ?? 'Move down'}: ${nameOf(column.name)}`}>\n ↓\n </Button>\n </span>\n </li>\n ),\n )}\n </ul>\n )\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n render={\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"max-sm:h-11\">\n <LayoutColumns />\n <span className=\"hidden lg:inline\">{t('columns.customize', 'Customize Columns') ?? 'Customize Columns'}</span>\n <span className=\"lg:hidden\">{t('columns.title', 'Columns') ?? 'Columns'}</span>\n <ChevronDown />\n </Button>\n }\n />\n <PopoverContent align=\"end\" className=\"flex max-h-[min(var(--available-height),28rem)] w-56 flex-col gap-3 overflow-y-auto p-3\">\n <div className=\"flex flex-col gap-1\">\n <p className=\"text-xs font-medium uppercase text-muted-foreground\">{t('columns.shown', 'Shown') ?? 'Shown'}</p>\n {section(true)}\n </div>\n <div className=\"flex flex-col gap-1\">\n <p className=\"text-xs font-medium uppercase text-muted-foreground\">{t('columns.hidden', 'Not shown') ?? 'Not shown'}</p>\n {section(false)}\n </div>\n </PopoverContent>\n </Popover>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 94cd106d0ec41f77e03f3e843d130f7cf848c1c56619ab392d98a5d0d95bd0f1\n",
18
+ "type": "registry:component",
19
+ "target": "@components/mithaq/list-columns.tsx"
20
+ }
21
+ ],
22
+ "type": "registry:component"
23
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "mithaq-list-filters",
4
+ "title": "Mithaq list filters",
5
+ "description": "The where panel: rows of field, operator and value joined by and or or, offered only for a field that declares itself filterable.",
6
+ "registryDependencies": [
7
+ "button",
8
+ "input",
9
+ "select",
10
+ "@mithaq/mithaq-controls",
11
+ "@mithaq/mithaq-filter-operators",
12
+ "@mithaq/mithaq-list-query",
13
+ "@mithaq/mithaq-relationship-control"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "registry/list-filters.tsx",
18
+ "content": "import { useEffect, useRef, useState, type ReactElement } from 'react'\nimport type { ClientKayan } from 'mithaq/admin'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { controls, labelFor, type FindApi, type Translate } from './controls.tsx'\nimport { RelationshipControl } from './relationship-control.tsx'\nimport { operatorsByKind, valueShapeOf, type FilterPath } from './filter-operators.ts'\nimport { instantOf, type Condition, type FilterWhere } from './list-query.ts'\n\nexport type FiltersProps = {\n /** Every path this kayan offers, already narrowed by the access report. */\n paths: readonly FilterPath[]\n value?: FilterWhere\n /** Where a committed panel goes. `undefined` is \"no filter at all\". */\n onChange: (where: FilterWhere | undefined) => void\n t: Translate\n locale: string\n /** Every kayan of the build, so a relationship row's picker can find its target. */\n client?: readonly ClientKayan[]\n /** The typed client's reads, for that picker's own paging. */\n api?: FindApi\n}\n\ntype Row = { path: string; operator: string; value: unknown }\n\nconst rowOf = (condition: Condition): Row => {\n const [path = '', operators = {}] = Object.entries(condition)[0] ?? []\n const [operator = 'equals', value] = Object.entries(operators)[0] ?? []\n return { path, operator, value }\n}\n\nconst conditionOf = (row: Row): Condition => ({ [row.path]: { [row.operator]: row.value } })\n\n/**\n * The rows the panel holds: the `or` of `and`s a `FilterWhere` carries. Held as state, not derived\n * from `value`, because a row with no value yet does not survive the round trip through the URL.\n */\nexport type Conditions = readonly { and: Condition[] }[]\n\n/** What a set of rows commits: the filled rows, and no group that keeps none. */\nexport const committedOf = (conditions: Conditions): FilterWhere | undefined => {\n const kept = conditions\n .map((group) => ({ and: group.and.filter((one) => !isEmptyConditionValue(rowOf(one).value)) }))\n .filter((group) => group.and.length > 0)\n return kept.length ? { or: kept } : undefined\n}\n\n/** One more row, in a group of its own; `and` is reached by adding and then switching the join. */\nexport const addCondition = (conditions: Conditions, condition: Condition): Conditions => [\n ...conditions,\n { and: [condition] },\n]\n\n/** One row gone, and the group it emptied gone with it. */\nexport const removeCondition = ({ conditions, orIndex, andIndex }: { conditions: Conditions; orIndex: number; andIndex: number }): Conditions =>\n conditions\n .map((group, o) => (o === orIndex ? { and: group.and.filter((_, a) => a !== andIndex) } : group))\n .filter((group) => group.and.length > 0)\n\n/** One row rewritten in place. */\nexport const updateCondition = ({\n conditions,\n orIndex,\n andIndex,\n condition,\n}: {\n conditions: Conditions\n orIndex: number\n andIndex: number\n condition: Condition\n}): Conditions =>\n conditions.map((group, o) => (o === orIndex ? { and: group.and.map((one, a) => (a === andIndex ? condition : one)) } : group))\n\n/** Moves one row between `and` and `or` by flattening the groups to one list and rebuilding them from each row's join. */\nexport const updateJoin = ({\n conditions,\n orIndex,\n andIndex,\n join,\n}: {\n conditions: Conditions\n orIndex: number\n andIndex: number\n join: 'and' | 'or'\n}): Conditions => {\n const flattened: { condition: Condition; join: 'and' | 'or' | null }[] = []\n conditions.forEach((group, o) => {\n group.and.forEach((condition, a) => {\n const isTarget = o === orIndex && a === andIndex\n const current = flattened.length === 0 ? null : a === 0 ? 'or' : 'and'\n flattened.push({ condition, join: isTarget ? join : current })\n })\n })\n const rebuilt: { and: Condition[] }[] = []\n flattened.forEach(({ condition, join: itemJoin }, index) => {\n if (index === 0 || itemJoin === 'or') rebuilt.push({ and: [condition] })\n else rebuilt[rebuilt.length - 1]!.and.push(condition)\n })\n return rebuilt\n}\n\nconst isEmptyConditionValue = (value: unknown): boolean => value === undefined || value === null || value === ''\n\n/**\n * An empty row reports `undefined` as soon as it mounts, and committing that stores\n * `{ [operator]: undefined }`, a row gone on the next parse. Only a value edit can be a no-op.\n */\nconst isNoOpConditionValueUpdate = (stored: unknown, incoming: unknown): boolean =>\n isEmptyConditionValue(stored) && isEmptyConditionValue(incoming)\n\n// A select item's value may not be '' (the primitive reserves it for nothing chosen), so an unanswered row is carried under this name and mapped back to ''.\nconst unanswered = 'unanswered'\n\n// `like` needs telling apart from `contains`: the drizzle store runs one `ilike` per whitespace-separated word. Labels go through `t` under `operator.<name>`.\nconst operatorFallbacks: Readonly<Record<string, string>> = {\n contains: 'contains',\n equals: 'is',\n exists: 'exists',\n greater_than: 'is after',\n greater_than_equal: 'is at least',\n in: 'is one of',\n less_than: 'is before',\n less_than_equal: 'is at most',\n like: 'matches every word',\n not_equals: 'is not',\n not_in: 'is none of',\n}\n\nconst asText = (value: unknown) =>\n value === null || value === undefined ? '' : Array.isArray(value) ? value.join(', ') : String(value)\n\n// A moment typed with no zone becomes the instant it names on the viewer's clock; a moment with its own zone, and text no clock reads, are sent as typed for the server to judge.\nconst valueFrom = (kind: string, operator: string, text: string): unknown => {\n const shape = valueShapeOf(operator)\n if (shape === 'boolean') return text === '' ? undefined : text === 'true'\n if (shape === 'list') {\n const tokens = text.split(',').map((one) => one.trim()).filter(Boolean)\n return tokens.length ? (kind === 'number' || kind === 'integer' ? tokens.map(Number) : kind === 'datetime' ? tokens.map(instantOf) : tokens) : undefined\n }\n if (text === '') return undefined\n if (kind === 'checkbox') return text === 'true'\n return kind === 'number' || kind === 'integer' ? Number(text) : kind === 'datetime' ? instantOf(text) : text\n}\n\nfunction ValueInput({ id, locale, operator, onCommit, path, t, value }: { id: string; locale: string; operator: string; path: FilterPath; t: Translate; value: unknown; onCommit: (value: unknown) => void }): ReactElement {\n const { kind } = path\n const [text, setText] = useState(asText(value))\n // `sent` is the last commit read back as text; it differs from the typed text where the commit converts it.\n const reported = useRef(asText(value))\n const sent = useRef(asText(value))\n useEffect(() => {\n // Re-seeding from this input's own commit coming back would drop keystrokes typed since, or swap the moment typed for its instant.\n const incoming = asText(value)\n if (incoming !== sent.current) {\n reported.current = sent.current = incoming\n setText(incoming)\n }\n }, [value])\n useEffect(() => {\n if (text === reported.current) return\n const timer = setTimeout(() => {\n const next = valueFrom(kind, operator, text)\n reported.current = text\n sent.current = asText(next)\n onCommit(next)\n }, 300)\n return () => clearTimeout(timer)\n }, [kind, onCommit, operator, text])\n\n const shape = valueShapeOf(operator)\n if (shape === 'boolean' || kind === 'checkbox') {\n return (\n <Select value={text === '' ? unanswered : text} onValueChange={(next) => setText(next === null || next === unanswered ? '' : next)}>\n {/* The word is the trigger's own child: the item list mounts only while open, so a server render would show an empty trigger. */}\n <SelectTrigger aria-label=\"value\" size=\"sm\">\n <SelectValue>{text === '' ? '—' : text}</SelectValue>\n </SelectTrigger>\n <SelectContent>\n <SelectItem value={unanswered}>—</SelectItem>\n <SelectItem value=\"true\">true</SelectItem>\n <SelectItem value=\"false\">false</SelectItem>\n </SelectContent>\n </Select>\n )\n }\n // A moment is sent as its ISO instant, so 09:30 picked at UTC+3 asks for 06:30Z and the picker draws it back on the reader's clock. `in` and `not_in` keep the token list.\n if ((kind === 'date' || kind === 'datetime') && shape !== 'list') {\n const Picker = controls[kind]!\n return (\n <Picker\n field={path.field}\n kayan={path.kayan}\n values={{}}\n setValue={() => {}}\n t={t}\n locale={locale}\n id={id}\n value={text}\n readOnly={false}\n onChange={(next) => setText(next instanceof Date ? next.toISOString() : asText(next))}\n />\n )\n }\n // A relationship reaches this arm only for `in` and `not_in`, a list of ids; its scalar operators get the picker.\n const type = shape === 'list' ? 'text' : kind === 'number' || kind === 'integer' ? 'number' : kind === 'email' ? 'email' : 'text'\n return (\n <Input\n aria-label=\"value\"\n className=\"w-auto\"\n type={type}\n step={kind === 'integer' && shape !== 'list' ? '1' : undefined}\n inputMode={kind === 'decimal' ? 'decimal' : undefined}\n dir=\"auto\"\n value={text}\n placeholder={shape === 'list' ? 'a, b, c' : undefined}\n onChange={(event) => setText(event.target.value)}\n />\n )\n}\n\nexport function Filters({ api, client, locale, onChange, paths, t, value }: FiltersProps): ReactElement | null {\n const [conditions, setConditions] = useState<Conditions>(() => value?.or ?? [])\n // What this panel commits comes back through the URL with the unfilled rows gone; re-seeding from that would drop the row being written. A `value` it did not report is an outside change and does re-seed.\n const reported = useRef(JSON.stringify(value ?? null))\n useEffect(() => {\n const incoming = JSON.stringify(value ?? null)\n if (incoming === reported.current) return\n reported.current = incoming\n setConditions(value?.or ?? [])\n }, [value])\n\n // No panel for a kayan with no filterable field; after the hooks, which must run on every render.\n if (!paths.length) return null\n const first = paths[0]!\n const flat = conditions.flatMap((group, orIndex) => group.and.map((condition, andIndex) => ({ andIndex, condition, orIndex })))\n const blank = (): Condition => ({ [first.path]: { [(operatorsByKind[first.kind] ?? ['equals'])[0]!]: undefined } })\n\n const commit = (next: Conditions) => {\n setConditions(next)\n const where = committedOf(next)\n const encoded = JSON.stringify(where ?? null)\n reported.current = encoded\n // Nothing goes up when the commit equals the filter the host holds: a host resets to page one on any `onChange`, so Add filter on page three would lose the page.\n if (encoded !== JSON.stringify(value ?? null)) onChange(where)\n }\n\n const replace = (orIndex: number, andIndex: number, row: Row) =>\n commit(updateCondition({ conditions, orIndex, andIndex, condition: conditionOf(row) }))\n\n // With nothing committed the panel shows the virtual row below, so the click makes it real and appends beside it.\n const add = () => commit(flat.length ? addCondition(conditions, blank()) : [{ and: [blank()] }, { and: [blank()] }])\n\n const labelOf = (path: FilterPath) => {\n const own = labelFor({ kayan: path.kayan, field: path.field, copy: { t, locale } })\n return path.through && path.through !== path.field.name ? `${path.through} > ${own}` : own\n }\n\n // The virtual first row commits on the first real edit rather than on mount, so an untouched panel writes no URL.\n const rows = flat.length ? flat : [{ andIndex: 0, condition: blank(), orIndex: 0 }]\n\n return (\n <div className=\"flex flex-col gap-2 rounded-lg border p-3\" data-mithaq-filters={rows.length}>\n {rows.map(({ andIndex, condition, orIndex }, index) => {\n const row = rowOf(condition)\n const path = paths.find((one) => one.path === row.path) ?? first\n const operators = operatorsByKind[path.kind] ?? []\n const operator = operators.includes(row.operator as never) ? row.operator : (operators[0] ?? 'equals')\n const virtual = flat.length === 0\n const write = (next: Row) => (virtual ? commit([{ and: [conditionOf(next)] }]) : replace(orIndex, andIndex, next))\n return (\n <div key={`${orIndex}.${andIndex}`} className=\"flex flex-wrap items-center gap-2\">\n {index === 0 ? (\n <span className=\"text-sm text-muted-foreground\">{t('filter.where', 'Where') ?? 'Where'}</span>\n ) : (\n <Select\n value={andIndex === 0 ? 'or' : 'and'}\n onValueChange={(next) => commit(updateJoin({ conditions, orIndex, andIndex, join: next === 'or' ? 'or' : 'and' }))}\n >\n <SelectTrigger aria-label={t('filter.join', 'Join') ?? 'Join'} size=\"sm\">\n <SelectValue>\n {andIndex === 0 ? (t('filter.or', 'or') ?? 'or') : (t('filter.and', 'and') ?? 'and')}\n </SelectValue>\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"and\">{t('filter.and', 'and') ?? 'and'}</SelectItem>\n <SelectItem value=\"or\">{t('filter.or', 'or') ?? 'or'}</SelectItem>\n </SelectContent>\n </Select>\n )}\n <Select\n value={path.path}\n onValueChange={(chosen) => {\n const next = paths.find((one) => one.path === chosen) ?? first\n const kept = (operatorsByKind[next.kind] ?? []).includes(operator as never) ? operator : (operatorsByKind[next.kind] ?? ['equals'])[0]!\n write({ path: next.path, operator: kept, value: undefined })\n }}\n >\n <SelectTrigger aria-label={t('filter.field', 'Field') ?? 'Field'} size=\"sm\">\n <SelectValue>{labelOf(path)}</SelectValue>\n </SelectTrigger>\n <SelectContent>\n {paths.map((one) => (\n <SelectItem key={one.path} value={one.path}>\n {labelOf(one)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <Select value={operator} onValueChange={(chosen) => chosen && write({ path: path.path, operator: chosen, value: undefined })}>\n <SelectTrigger aria-label={t('filter.operator', 'Operator') ?? 'Operator'} size=\"sm\">\n <SelectValue>{t(`operator.${operator}`, operatorFallbacks[operator]) ?? operator}</SelectValue>\n </SelectTrigger>\n <SelectContent>\n {operators.map((one) => (\n <SelectItem key={one} value={one}>\n {t(`operator.${one}`, operatorFallbacks[one]) ?? one}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n {path.kind === 'relationship' && valueShapeOf(operator) === 'scalar' ? (\n // The form's picker rather than a box for typing a uuid; `in` and `not_in` keep the token list because the picker settles on one id.\n <RelationshipControl\n field={path.field}\n values={{}}\n setValue={() => {}}\n kayan={path.kayan}\n client={client}\n api={api}\n t={t}\n id={`filter-${orIndex}-${andIndex}`}\n value={row.value}\n readOnly={false}\n onChange={(next) => {\n if (isNoOpConditionValueUpdate(row.value, next)) return\n write({ path: path.path, operator, value: next })\n }}\n />\n ) : (\n <ValueInput\n id={`filter-${orIndex}-${andIndex}`}\n locale={locale}\n path={path}\n t={t}\n operator={operator}\n value={row.value}\n onCommit={(next) => {\n if (isNoOpConditionValueUpdate(row.value, next)) return\n write({ path: path.path, operator, value: next })\n }}\n />\n )}\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => commit(virtual ? [] : removeCondition({ conditions, orIndex, andIndex }))}>\n {t('filter.remove', 'Remove') ?? 'Remove'}\n </Button>\n </div>\n )\n })}\n <div>\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={add}>\n {t('filter.add', 'Add filter') ?? 'Add filter'}\n </Button>\n </div>\n </div>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 8606b5fd4339471a01d4f8ff1c783d4c79ae3c0f92bd195586d04260a77a9ea0\n",
19
+ "type": "registry:component",
20
+ "target": "@components/mithaq/list-filters.tsx"
21
+ }
22
+ ],
23
+ "type": "registry:component"
24
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "mithaq-list-pagination",
4
+ "title": "Mithaq list pagination",
5
+ "description": "Two arrows, a page input clamped to the page count, the per-page popup and the range line, all off the paginated shape the read answered.",
6
+ "registryDependencies": [
7
+ "button",
8
+ "input",
9
+ "label",
10
+ "select",
11
+ "@mithaq/mithaq-controls",
12
+ "@mithaq/mithaq-directional-icon",
13
+ "@mithaq/mithaq-list-query"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "registry/list-pagination.tsx",
18
+ "content": "import { useEffect, useState, type ReactElement } from 'react'\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { DirectionalIcon } from '@/components/ui/directional-icon'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport type { Translate } from './controls.tsx'\nimport { useScreen } from 'mithaq/admin/react'\nimport { limitsUpTo } from './list-query.ts'\n\n/** As much of one page as the pager reads. */\nexport type PageInfo = {\n hasNextPage: boolean\n hasPrevPage: boolean\n limit: number\n nextPage: number | null\n page: number\n prevPage: number | null\n totalDocs: number\n totalPages: number\n}\n\nexport type PaginationProps = {\n page: PageInfo\n onPage: (page: number) => void\n /** A new page size. The host resets the page to one, as it does for a filter. */\n onLimit: (limit: number) => void\n t: Translate\n locale: string\n}\n\n// The trigger is a button, not a native select, so the label points at it by id; one pager renders per screen.\nconst perPageId = 'mithaq-per-page'\n\nexport function Pagination({ locale, onLimit, onPage, page, t }: PaginationProps): ReactElement {\n const [typed, setTyped] = useState(String(page.page))\n useEffect(() => setTyped(String(page.page)), [page.page])\n const { maxLimit } = useScreen()\n\n const clamp = () => {\n const parsed = Number.parseInt(typed, 10)\n const within = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 1), Math.max(page.totalPages, 1)) : page.page\n setTyped(String(within))\n if (within !== page.page) onPage(within)\n }\n\n const from = page.totalDocs === 0 ? 0 : (page.page - 1) * page.limit + 1\n const to = Math.min(page.page * page.limit, page.totalDocs)\n\n return (\n <footer className=\"flex flex-wrap items-center justify-between gap-x-8 gap-y-3 px-2\">\n <span className=\"text-muted-foreground flex flex-1 text-sm whitespace-nowrap\" data-mithaq-range={`${from}-${to}/${page.totalDocs}`}>\n {from.toLocaleString(locale)}–{to.toLocaleString(locale)} {t('admin.of', 'of') ?? 'of'} {page.totalDocs.toLocaleString(locale)}\n </span>\n {/* `contents` on a phone, so the size box joins the range's line and the rest wraps. */}\n <div className=\"ms-auto flex items-center gap-8 max-sm:contents\">\n <div className=\"flex items-center gap-2\">\n <Label className=\"text-sm font-medium\" htmlFor={perPageId}>\n {t('admin.perPage', 'Per page') ?? 'Per page'}\n </Label>\n {/* The size is the trigger's own child: the item list mounts only while open, so a server render would show an empty trigger. */}\n <Select value={String(page.limit)} onValueChange={(next: string | null) => next && onLimit(Number(next))}>\n <SelectTrigger id={perPageId} size=\"sm\" className=\"h-8 w-[70px] max-sm:data-[size=sm]:h-11\">\n <SelectValue>{page.limit}</SelectValue>\n </SelectTrigger>\n <SelectContent side=\"top\">\n {limitsUpTo(maxLimit).map((one) => (\n <SelectItem key={one} value={String(one)}>\n {one}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n <div className=\"flex w-fit items-center justify-center gap-2 text-sm font-medium\">\n <Input\n className=\"h-8 w-16 text-center max-sm:h-11\"\n type=\"number\"\n min={1}\n max={Math.max(page.totalPages, 1)}\n value={typed}\n aria-label={t('admin.page', 'Page') ?? 'Page'}\n onChange={(event) => setTyped(event.target.value)}\n onBlur={clamp}\n onKeyDown={(event) => {\n if (event.key === 'Enter') clamp()\n if (event.key === 'Escape') setTyped(String(page.page))\n }}\n />\n <span className=\"text-muted-foreground\">\n {t('admin.of', 'of') ?? 'of'} {Math.max(page.totalPages, 1).toLocaleString(locale)}\n </span>\n </div>\n <div className=\"ms-auto flex items-center gap-2 sm:ms-0\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"hidden size-8 sm:flex\"\n disabled={!page.hasPrevPage}\n onClick={() => onPage(1)}\n >\n <span className=\"sr-only\">{t('admin.first', 'First') ?? 'First'}</span>\n <DirectionalIcon>\n <ChevronsLeft />\n </DirectionalIcon>\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 max-sm:size-11\"\n disabled={!page.hasPrevPage}\n onClick={() => onPage(page.prevPage ?? 1)}\n >\n <span className=\"sr-only\">{t('admin.previous', 'Previous') ?? 'Previous'}</span>\n <DirectionalIcon>\n <ChevronLeft />\n </DirectionalIcon>\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 max-sm:size-11\"\n disabled={!page.hasNextPage}\n onClick={() => onPage(page.nextPage ?? page.page)}\n >\n <span className=\"sr-only\">{t('admin.next', 'Next') ?? 'Next'}</span>\n <DirectionalIcon>\n <ChevronRight />\n </DirectionalIcon>\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"hidden size-8 sm:flex\"\n disabled={!page.hasNextPage}\n onClick={() => onPage(Math.max(page.totalPages, 1))}\n >\n <span className=\"sr-only\">{t('admin.last', 'Last') ?? 'Last'}</span>\n <DirectionalIcon>\n <ChevronsRight />\n </DirectionalIcon>\n </Button>\n </div>\n </div>\n </footer>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 78d68b28893401072ea1b499a0b3c22588ded882bd7964122ccbb0048698d373\n",
19
+ "type": "registry:component",
20
+ "target": "@components/mithaq/list-pagination.tsx"
21
+ }
22
+ ],
23
+ "type": "registry:component"
24
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "mithaq-list-query",
4
+ "title": "Mithaq list query",
5
+ "description": "The list's URL in Payload's qs form, read against the list with a hostile or unknown filter refused before any request, the find input a page asks with, the per-viewer half remembered in localStorage, and the search term folded into a where.",
6
+ "registryDependencies": [
7
+ "@mithaq/mithaq-filter-operators"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "registry/list-query.ts",
12
+ "content": "import { isDecimal, type ClientField, type ClientKayan } from 'mithaq/admin'\nimport { filterPathsOf, offers, valueShapeOf, type FilterContext, type FilterPath } from './filter-operators.ts'\n\n// The reader is a trust boundary: any bad key, field, operator or value refuses the whole URL (`QueryRefused`),\n// and a link naming a field the viewer may not read is refused, never read as a smaller filter.\n\n/** One row of the panel: a path, an operator and its value. */\nexport type Condition = Record<string, Record<string, unknown>>\n\n/** The panel's state, two levels deep and never deeper: a disjunction of conjunctions, the shape of a `where` the server takes. */\nexport type FilterWhere = { or: { and: Condition[] }[] }\n\n/** What the screen drives `find` with, and what the URL carries. */\nexport type ListQuery = {\n where?: FilterWhere\n search?: string\n sort?: string\n page?: number\n limit?: number\n /** The column accessor, prefixed `-` when the column is hidden. */\n cols?: readonly string[]\n}\n\n/** One column as the popup holds it, which is what the `-` prefix encodes. */\nexport type ColumnChoice = { name: string; active: boolean }\n\n/** The URL's column list read back into choices; a `-` prefix marks a hidden column. */\nexport const columnsFrom = (cols: readonly string[] | undefined): ColumnChoice[] =>\n (cols ?? []).map((one) => (one.startsWith('-') ? { name: one.slice(1), active: false } : { name: one, active: true }))\n\n/** The choices written back as the URL's column list. */\nexport const columnsTo = (columns: readonly ColumnChoice[]): string[] =>\n columns.map((one) => (one.active ? one.name : `-${one.name}`))\n\n/** The page sizes the screen offers. The first is the one a list opens with. */\nexport const limits = [10, 25, 50, 100] as const\n\n/**\n * The page sizes a build whose served read answers at most `maxLimit` rows\n * offers: the ones up to it, since a larger one is a 400, or the maximum alone\n * where none of them fits.\n */\nexport const limitsUpTo = (maxLimit: number): readonly number[] => {\n const fit = limits.filter((one) => one <= maxLimit)\n return fit.length ? fit : [maxLimit]\n}\n\nconst whole = (value: unknown) => {\n const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10)\n return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined\n}\n\n/** Why a URL was refused, which the host shows in place of the rows it never asked for. */\nexport class QueryRefused extends Error {\n override name = 'QueryRefused'\n}\nfunction refuse(sentence: string): never {\n throw new QueryRefused(`This link ${sentence}`)\n}\n\n/** Whether a field can order a page: the same questions the read answers before it takes a sort. */\nexport const canSort = (field: ClientField) =>\n field.rules.localized !== true && field.hasMany !== true && field.kind !== 'join' && field.sortable !== false\n\n/** The fields a list may be sorted by. */\nexport const sortableOf = (kayan: { fields: readonly ClientField[] }) => kayan.fields.filter(canSort).map((field) => field.name)\n\n/**\n * What a URL is read against: the paths the list filters by, the fields it\n * sorts by, and the list node's own page size, which is one the URL may name\n * whether or not the pager offers it.\n */\nexport type ListRules = { paths: readonly FilterPath[]; sortable: readonly string[]; limit?: number; maxLimit: number }\n\n/** What a list node says about its query: what it filters and searches by, and the sort and limit it opens with. */\nexport type ListDefaults = { filters?: readonly string[]; search?: readonly string[]; sort?: string; limit?: number }\n\n/** The rules one list node reads its URL by. */\nexport const rulesOf = ({ kayan, node, maxLimit, context = {} }: { kayan: ClientKayan; node: ListDefaults | undefined; maxLimit: number; context?: FilterContext }): ListRules => ({\n paths: filterPathsOf(kayan, { ...context, ...(node?.filters && { filters: node.filters }) }),\n sortable: sortableOf(kayan),\n maxLimit,\n ...(node?.limit !== undefined && { limit: node.limit }),\n})\n\n/** A `where` in `qs`'s bracket form: `where[or][0][and][0][title][like]`. */\nconst bracketed = /^where((?:\\[[^[\\]]*\\])+)$/\n/** `where[or][0][and][0][path][operator][index]`: seven segments and never more. */\nconst depth = 7\n/** A `where` is at most this many values, which is far past what the panel writes. */\nconst most = 64\n/** The three keys that reach an object's prototype, which a `where` never names. */\nconst unsafe = ['__proto__', 'constructor', 'prototype']\n\n/** The `where` pairs of a URL built into the object they spell; anything `qs` would silently repair is refused. */\nconst treeOf = (pairs: readonly [string, string][]): unknown => {\n if (pairs.length > most) refuse(`carries more than ${most} where values.`)\n const tree: Record<string, unknown> = {}\n for (const [key, value] of pairs) {\n const segments = [...(bracketed.exec(key)?.[1] ?? refuse('carries a where that is not written in brackets.')).matchAll(/\\[([^[\\]]*)\\]/g)].map((one) => one[1]!)\n if (segments.length > depth) refuse('is nested deeper than a where goes.')\n let at: Record<string, unknown> | unknown[] = tree\n segments.forEach((segment, index) => {\n if (unsafe.includes(segment)) refuse(`names \"${segment}\", which no where may name.`)\n const numbered = /^\\d{1,2}$/.test(segment)\n if (numbered !== Array.isArray(at) || segment === '') refuse(`mixes a list and an object at \"${segment}\".`)\n const slot = at as Record<string, unknown>\n if (index === segments.length - 1) {\n if (slot[segment] !== undefined) refuse(`says \"${key}\" twice.`)\n slot[segment] = value\n return\n }\n slot[segment] ??= /^\\d{1,2}$/.test(segments[index + 1]!) ? [] : {}\n if (typeof slot[segment] !== 'object') refuse(`says \"${key}\" twice.`)\n at = slot[segment] as Record<string, unknown> | unknown[]\n })\n }\n const gapless = (value: unknown): boolean =>\n !value || typeof value !== 'object' || ((!Array.isArray(value) || [...value].every((one) => one !== undefined)) && Object.values(value).every(gapless))\n if (!gapless(tree)) refuse('holds a gap in its list.')\n return tree\n}\n\n/**\n * A `where` hoisted into the panel's two levels: an `or` whose entries hold no\n * `and` gets one, a bare `and` is one group, and top-level siblings are one\n * group of their own.\n */\nconst hoisted = (where: Record<string, unknown>) => {\n const or = where.or as Record<string, unknown>[] | undefined\n const and = where.and as unknown[] | undefined\n // `where[or][title]` builds an object where a list belongs, which the read would refuse.\n if (or !== undefined && !Array.isArray(or)) refuse('holds an or that is no list.')\n if (and !== undefined && !Array.isArray(and)) refuse('holds an and that is no list.')\n if (or && and) refuse('holds or and and at the root of one where.')\n if (or) return or.map((group) => (Array.isArray(group.and) ? group.and : [group]))\n if (and) return [and]\n return [[where]]\n}\n\n/**\n * A moment with no zone, `2026-10-07T09:30`, as the ISO instant on the reader's own clock.\n * Anything else (a moment with its zone, text no clock reads) is handed back for the server to judge.\n */\nexport const instantOf = (text: string): string => {\n if (!/^\\d{4}-\\d\\d-\\d\\d[T ]\\d\\d:\\d\\d(?::\\d\\d(?:\\.\\d{1,3})?)?$/.test(text)) return text\n const local = new Date(text.replace(' ', 'T'))\n return Number.isNaN(local.getTime()) ? text : local.toISOString()\n}\n\n/** One value in the kind of its field: the URL carries strings, and the read takes typed values. */\nconst valueOf = (path: FilterPath, operator: string, value: unknown): unknown => {\n if (typeof value !== 'string') refuse(`asks field \"${path.path}\" for \"${operator}\" with a value that is no single value.`)\n if (path.kind === 'number' || path.kind === 'integer') {\n const number = Number(value)\n const integer = path.kind === 'integer'\n const held = value.trim() !== '' && (integer ? Number.isInteger(number) && number >= -2147483648 && number <= 2147483647 : Number.isFinite(number))\n return held ? number : refuse(`asks field \"${path.path}\" to equal \"${value}\", which is not ${integer ? 'a 32-bit integer' : 'a number'}.`)\n }\n // A decimal stays the text it is, which is the form its row holds.\n if (path.kind === 'decimal') return isDecimal(value) ? value : refuse(`asks field \"${path.path}\" to equal \"${value}\", which is not a decimal.`)\n if (path.kind === 'checkbox') return value === 'true' ? true : value === 'false' ? false : refuse(`asks field \"${path.path}\" to equal \"${value}\", which is not true or false.`)\n // A moment with no zone is read on the reader's own clock, not handed on for the store to read as UTC.\n if (path.kind === 'datetime') return instantOf(value)\n return value\n}\n\n/** A `where` off the URL, every condition checked the way the read checks one, so nothing is asked of the server that it would refuse. */\nconst whereOf = (paths: readonly FilterPath[], tree: unknown): FilterWhere | undefined => {\n const where = tree as Record<string, unknown>\n if (!Object.keys(where).length) return undefined\n const groups = hoisted(where).map((conditions) => ({\n and: conditions.flatMap((condition) =>\n Object.entries(condition as Record<string, unknown>).flatMap(([name, operators]) => {\n const path = paths.find((one) => one.path === name) ?? refuse(`names field \"${name}\", which this list does not filter by.`)\n if (!operators || typeof operators !== 'object' || Array.isArray(operators)) refuse(`names field \"${name}\" with no operator.`)\n return Object.entries(operators as Record<string, unknown>).map(([operator, value]): Condition => {\n if (!offers({ paths, path: name, operator })) refuse(`asks field \"${name}\" for \"${operator}\", which it does not take.`)\n const shape = valueShapeOf(operator)\n if (shape === 'list' && !Array.isArray(value)) refuse(`asks field \"${name}\" for \"${operator}\" with one value, and \"${operator}\" takes a list.`)\n const typed =\n shape === 'boolean' ? valueOf({ ...path, kind: 'checkbox' }, operator, value)\n : shape === 'list' ? (value as unknown[]).map((one) => valueOf(path, operator, one))\n : valueOf(path, operator, value)\n return { [name]: { [operator]: typed } }\n })\n }),\n ),\n }))\n return { or: groups }\n}\n\n/** What a reader is handed: the URL's own search params, or the router's record of them. */\nexport type QuerySource = URLSearchParams | Readonly<Record<string, unknown>>\n\n/**\n * The URL's list keys as the strings it carries: every `where[…]` pair,\n * `search`, `sort`, `page`, `limit` and `cols`. A route validates its search\n * into this before any kayan is in hand; `readQuery` reads it against the list.\n */\nexport type RawQuery = Readonly<Record<string, string>>\n\n/** A key with a list-of-another-kayan prefix taken off (`sessions.course.page` is `page`), so two lists on one page never read each other's. */\nconst baseOf = (key: string) => key.replace(/^[^.[\\]]+\\.[^.[\\]]+\\./, '')\nconst listKey = (key: string) => {\n const base = baseOf(key)\n return base.startsWith('where') || ['search', 'sort', 'page', 'limit', 'cols'].includes(base)\n}\n\n/** The prefix one list node's keys carry in the URL: none for a page's own list, `<of>.<where>.` for a list of another kayan. */\nexport const prefixOf = (node: { of?: string; where?: string } | undefined) => (node?.of ? `${node.of}.${node.where}.` : '')\n\nconst ownKey = (key: string, prefix: string) => key.startsWith(prefix) && baseOf(key) === key.slice(prefix.length)\n\n/** One list's own keys off the URL's, its prefix taken off, which is what `readQuery` reads. */\nexport const listRaw = (raw: RawQuery, prefix: string): RawQuery =>\n Object.fromEntries(Object.entries(raw).flatMap(([key, value]) => (ownKey(key, prefix) ? [[key.slice(prefix.length), value]] : [])))\n\n/** The URL's keys with one list's replaced by what it now asks, every other list's kept. */\nexport const withList = ({ raw, prefix, written }: { raw: RawQuery; prefix: string; written: Readonly<Record<string, string>> }): Record<string, string> => ({\n ...Object.fromEntries(Object.entries(raw).filter(([key]) => !ownKey(key, prefix))),\n ...Object.fromEntries(Object.entries(written).map(([key, value]) => [`${prefix}${key}`, value])),\n})\n\nexport const readRaw = (source: QuerySource): RawQuery =>\n Object.fromEntries(\n (source instanceof URLSearchParams ? [...source] : Object.entries(source)).filter(\n (pair): pair is [string, string] => listKey(pair[0]) && typeof pair[1] === 'string',\n ),\n )\n\n/**\n * The whole query, read against the list it is for, or `QueryRefused` for a\n * URL that asks for what the list does not offer.\n */\nexport const readQuery = (source: QuerySource, rules: ListRules): ListQuery => {\n const raw = readRaw(source)\n const where = whereOf(rules.paths, treeOf(Object.entries(raw).filter(([key]) => key.startsWith('where'))))\n const { search, sort, cols } = raw\n if (sort && !rules.sortable.includes(sort.replace(/^-/, ''))) refuse(`sorts by \"${sort.replace(/^-/, '')}\", which this list does not sort by.`)\n const page = whole(raw.page)\n const limit = whole(raw.limit)\n const list = (cols ?? '').split(',').map((one) => one.trim()).filter(Boolean)\n return {\n ...(where && { where }),\n ...(search && { search }),\n ...(sort && { sort }),\n ...(page && { page }),\n ...(limit && (limitsUpTo(rules.maxLimit).includes(limit) || limit === rules.limit) && { limit }),\n ...(list.length > 0 && { cols: list }),\n }\n}\n\n/** A value's pairs in bracket form: `where[or][0][and][0][title][like]`. */\nconst pairsOf = (prefix: string, value: unknown): [string, string][] =>\n value && typeof value === 'object'\n ? Object.entries(value).flatMap(([key, one]) => pairsOf(`${prefix}[${key}]`, one))\n : value === undefined ? [] : [[prefix, String(value)]]\n\n/**\n * The same query as a URL would carry it, one string per key and no key for a\n * value that is not there. Handed to `new URLSearchParams(...)` or to a\n * router's own `search`, which is why it is a record rather than a string.\n */\nexport const writeQuery = (query: ListQuery): Record<string, string> => ({\n ...Object.fromEntries(pairsOf('where', query.where)),\n ...(query.search && { search: query.search }),\n ...(query.sort && { sort: query.sort }),\n ...(query.page && query.page > 1 && { page: String(query.page) }),\n ...(query.limit && { limit: String(query.limit) }),\n ...(query.cols?.length && { cols: query.cols.join(',') }),\n})\n\n/**\n * The same query with one part changed and the page back at one: each of\n * filter, search, sort and limit changes which rows are on which page, and a\n * page number that outlived its result is a blank table.\n */\nexport const refined = (query: ListQuery, next: Partial<ListQuery>): ListQuery => ({ ...query, ...next, page: 1 })\n\n/**\n * The per-viewer state the URL does not carry: page size and columns, in one `localStorage` key per kayan.\n * Access is in a try/catch: `localStorage` throws in a private window and is absent in a server render.\n */\nexport type Remembered = { limit?: number; cols?: readonly string[] }\n\nconst keyFor = (kayan: string) => `mithaq.list.${kayan}`\n\nexport const remembered = (kayan: string, maxLimit: number): Remembered => {\n try {\n const held = globalThis.localStorage?.getItem(keyFor(kayan))\n const value = held ? (JSON.parse(held) as unknown) : undefined\n if (!value || typeof value !== 'object') return {}\n const { cols, limit } = value as Remembered\n return {\n ...(typeof limit === 'number' && limitsUpTo(maxLimit).includes(limit) && { limit }),\n ...(Array.isArray(cols) && cols.every((one) => typeof one === 'string') && { cols }),\n }\n } catch {\n return {}\n }\n}\n\nexport const remember = (kayan: string, value: Remembered): void => {\n try {\n globalThis.localStorage?.setItem(keyFor(kayan), JSON.stringify(value))\n } catch {\n // A viewer whose storage is full or blocked keeps the URL's answer, which\n // is the whole of what the screen needs to render.\n }\n}\n\n/** The half of a kayan a search reads: the two admin keys, the kinds, and where a relationship points. */\nexport type Searchable = {\n name?: string\n useAsTitle?: string\n /** The rows every list of the kayan on its screen is held to. */\n baseFilter?: unknown\n fields?: readonly { name: string; kind: string; searchable?: true; relationTo?: string; rules?: { localized?: unknown } }[]\n}\n\n/**\n * Whether the viewer may read what a search path names, in this kayan or across one relationship.\n * A projection holds only readable fields; a path it lacks would 403 and the search box would refuse every word.\n */\nconst reads = (kayan: Searchable, path: string, client: readonly Searchable[] = []) => {\n const [own, far] = path.split('.')\n const near = kayan.fields?.find((field) => field.name === own)\n if (!near || far === undefined) return near !== undefined\n return client.some((one) => one.name === near.relationTo && one.fields?.some((field) => field.name === far))\n}\n\n/**\n * The fields a search runs over: the node's `search`, else those declared `searchable`, else `useAsTitle`, else `id`.\n * Only the fallbacks need a text or email kind, as the read takes `like` on those alone; a uuid `id` has no search.\n */\nexport const searchableFields = ({ kayan, search, client }: { kayan: Searchable; search?: readonly string[]; client?: readonly Searchable[] }): readonly string[] => {\n const declared = search ?? (kayan.fields ?? []).filter((field) => field.searchable).map((field) => field.name)\n if (declared.length) return declared.filter((path) => reads(kayan, path, client))\n const name = kayan.useAsTitle ?? 'id'\n const kind = kayan.fields?.find((field) => field.name === name)?.kind\n return kind === 'text' || kind === 'email' ? [name] : []\n}\n\n/**\n * A search term as a `where`: one `like` per searchable field, ORed. `like` rather than `contains` because\n * the drizzle store splits it on whitespace and runs one `ilike` per word.\n */\nexport const searchWhere = ({\n kayan,\n search,\n fields = searchableFields({ kayan }),\n locales = [],\n}: {\n kayan: Searchable\n search: string | undefined\n fields?: readonly string[]\n locales?: readonly string[]\n}): FilterWhere | undefined => {\n const term = search?.trim()\n // A localized field is one column per locale and a `like` names one of them,\n // `name.ar`, so it is searched in every locale the host reads in.\n const paths = fields.flatMap((field) =>\n kayan.fields?.find((one) => one.name === field)?.rules?.localized === true ? locales.map((locale) => `${field}.${locale}`) : [field],\n )\n // A kayan with no searchable field is no filter at all, not a `where` of no\n // groups, which the server refuses as an empty disjunction.\n return term && paths.length ? { or: paths.map((field) => ({ and: [{ [field]: { like: term } }] })) } : undefined\n}\n\n/**\n * What one render hands `find`: the panel's `where`, the search's and the\n * screen's `baseFilter` for the kayan, ANDed in that order. One alone is passed\n * through rather than wrapped.\n */\nexport const findWhere = ({\n kayan,\n query,\n search,\n locales,\n client,\n}: {\n kayan: Searchable\n query: ListQuery\n search?: readonly string[]\n locales?: readonly string[]\n client?: readonly Searchable[]\n}): unknown => {\n const searched = searchWhere({ kayan, search: query.search, fields: searchableFields({ kayan, search, client }), locales })\n const parts = [query.where, searched, kayan.baseFilter].filter((one) => one !== undefined)\n return parts.length > 1 ? { and: parts } : parts[0]\n}\n\n/**\n * What one page asks `find`: the panel's and the search's `where`, and the URL's sort and limit over the node's defaults.\n * Server and browser both use it, so the first page and every later one share one query and cache key.\n */\nexport const findOf = ({\n kayan,\n query,\n node,\n locales,\n client,\n}: {\n kayan: Searchable\n query: ListQuery\n node?: ListDefaults\n locales?: readonly string[]\n client?: readonly Searchable[]\n}) => {\n const where = findWhere({ kayan, query, search: node?.search, locales, client })\n const sort = query.sort ?? node?.sort\n return {\n ...(where !== undefined && { where }),\n ...(sort && { sort }),\n ...(query.page && { page: query.page }),\n // None where neither names one, so the server's own `defaultLimit`\n // answers, which the build holds to its `maxLimit`.\n ...((query.limit ?? node?.limit) !== undefined && { limit: query.limit ?? node?.limit }),\n }\n}\n\n/**\n * A page's input held to the rows a list of another kayan draws: those whose\n * relationship holds the page's row, ANDed with whatever the URL asked. It is\n * part of the one input and the one cache key.\n */\nexport const scopedTo = (input: Readonly<Record<string, unknown>>, where: Readonly<Record<string, unknown>>): Record<string, unknown> => ({\n ...input,\n where: input.where === undefined ? where : { and: [where, input.where] },\n})\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 41bf95fa8c90940a8942f07ab193a03ad1381581c59b96c0ab92b784091895bd\n",
13
+ "type": "registry:lib",
14
+ "target": "@components/mithaq/list-query.ts"
15
+ }
16
+ ],
17
+ "type": "registry:lib"
18
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "mithaq-list-selection",
4
+ "title": "Mithaq list selection",
5
+ "description": "The selection a list's bulk buttons run over: the head checkbox, one per row, and the fields a bulk update may write, which are the ones a person could edit on a row.",
6
+ "registryDependencies": [
7
+ "checkbox",
8
+ "@mithaq/mithaq-controls"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "registry/list-selection.tsx",
13
+ "content": "import type { ReactElement } from 'react'\nimport type { ClientField, ClientKayan } from 'mithaq/admin'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { checkboxSize, readOnlyField, type Translate } from './controls.tsx'\n\n/**\n * The list's checkbox size, with the invisible hit layer grown to 44 pixels below `sm`. Only for\n * the list: a checkbox group's options sit 28 pixels apart, so a 44 pixel layer there would tick the option above.\n */\nexport const selectBox = `${checkboxSize} after:-inset-x-3.5 max-sm:after:-inset-y-3.5`\n\n/** Which fields a bulk update may offer: the ones a person could edit on a row of this kayan. Chrome only: the runner refuses whatever the list draws. */\nexport const writableFields = (\n kayan: ClientKayan,\n writes?: (field: string) => boolean,\n): readonly ClientField[] =>\n kayan.fields.filter(\n (field) =>\n !readOnlyField(field, 'update')\n && (writes ? writes(field.name) : true),\n )\n\n/** The head checkbox of the selection column; reports `aria-checked=\"mixed\"` while some of the page is ticked. */\nexport function SelectAll({\n ids,\n selected,\n onSelect,\n t,\n}: {\n ids: readonly string[]\n selected: ReadonlySet<string>\n onSelect: (ids: ReadonlySet<string>) => void\n t: Translate\n}): ReactElement {\n const ticked = ids.filter((one) => selected.has(one)).length\n const all = ids.length > 0 && ticked === ids.length\n return (\n <Checkbox\n className={selectBox}\n checked={all}\n aria-checked={ticked > 0 && !all ? 'mixed' : all}\n aria-label={t('admin.selectAll', 'Select every row on this page') ?? 'Select every row on this page'}\n onCheckedChange={(checked: boolean | 'indeterminate') => onSelect(checked === true ? new Set(ids) : new Set())}\n />\n )\n}\n\n/** One row's checkbox. */\nexport function SelectRow({\n id,\n selected,\n onSelect,\n t,\n}: {\n id: string\n selected: ReadonlySet<string>\n onSelect: (ids: ReadonlySet<string>) => void\n t: Translate\n}): ReactElement {\n return (\n <Checkbox\n className={selectBox}\n checked={selected.has(id)}\n aria-label={t('admin.selectRow', 'Select this row') ?? 'Select this row'}\n onCheckedChange={(checked: boolean | 'indeterminate') => {\n const next = new Set(selected)\n if (checked === true) next.add(id)\n else next.delete(id)\n onSelect(next)\n }}\n />\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 576cedc3611379cc2dcbda75ac13cbf42a5f1d6fbdd43ed6e56fd9d621f3e821\n",
14
+ "type": "registry:component",
15
+ "target": "@components/mithaq/list-selection.tsx"
16
+ }
17
+ ],
18
+ "type": "registry:component"
19
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "mithaq-list",
4
+ "title": "Mithaq list",
5
+ "description": "The list screen over a kayan's find, with search, filters, columns, sort, page and limit driven by one callback and every destination a prop, each row opening its record and carrying a menu of Edit, Delete and the list node's row buttons, a row button offered where its when holds over that row, and the toolbar and bulk buttons.",
6
+ "registryDependencies": [
7
+ "button",
8
+ "card",
9
+ "dropdown-menu",
10
+ "input",
11
+ "table",
12
+ "@mithaq/mithaq-cells",
13
+ "@mithaq/mithaq-controls",
14
+ "@mithaq/mithaq-filter-operators",
15
+ "@mithaq/mithaq-form",
16
+ "@mithaq/mithaq-list-columns",
17
+ "@mithaq/mithaq-list-filters",
18
+ "@mithaq/mithaq-list-pagination",
19
+ "@mithaq/mithaq-list-query",
20
+ "@mithaq/mithaq-list-selection",
21
+ "@mithaq/mithaq-nodes",
22
+ "@mithaq/mithaq-relationship-cell"
23
+ ],
24
+ "files": [
25
+ {
26
+ "path": "registry/list.tsx",
27
+ "content": "import { useEffect, useId, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactElement, type ReactNode } from 'react'\nimport { ArrowDown, ArrowUp, ChevronDown, MoreHorizontal, Plus, Search as SearchIcon, X } from 'lucide-react'\nimport type { ButtonClient, ClientField, ClientKayan, Row } from 'mithaq/admin'\nimport { useScreen } from 'mithaq/admin/react'\nimport { Button, buttonVariants } from '@/components/ui/button'\nimport { Card, CardContent } from '@/components/ui/card'\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'\nimport { Input } from '@/components/ui/input'\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'\nimport { NodeButton, OwnButton, drawsKind, isUntitled, listOf, nodesOf, pageButtonOf, refOf, rowTitle, rowWarning, scopeOf, shown as holds, type ListNode, type PageButton, type ScreenPage } from './nodes.tsx'\nimport { cellFor, cells } from './cells.tsx'\nimport { counted, descriptionOf, labelFor, operationLabelFor, refusalKeyOf, titleOf, type Copy, type FindApi, type Translate } from './controls.tsx'\nimport { DeleteButton, Form, OperationButton, type FormProps } from './form.tsx'\nimport { useRelationshipTitles } from './relationship-cell.tsx'\nimport { filterPathsOf } from './filter-operators.ts'\nimport { Filters } from './list-filters.tsx'\nimport { Columns, columnCandidates } from './list-columns.tsx'\nimport { Pagination } from './list-pagination.tsx'\nimport { SelectAll, SelectRow, writableFields } from './list-selection.tsx'\nimport {\n canSort,\n columnsFrom,\n columnsTo,\n refined,\n remember,\n remembered,\n searchableFields,\n type ColumnChoice,\n type FilterWhere,\n type ListQuery,\n} from './list-query.ts'\n\n/** One page, as the managed reader answers it. */\nexport type Page = {\n docs: readonly Record<string, unknown>[]\n hasNextPage: boolean\n hasPrevPage: boolean\n limit: number\n nextPage: number | null\n page: number\n prevPage: number | null\n totalDocs: number\n totalPages: number\n}\n\n/** The six options this screen drives `find` and the URL with. */\nexport type Query = ListQuery\n\nexport type ListProps = {\n kayan: ClientKayan\n page: Page\n query: Query\n t: Translate\n locale: string\n /**\n * Which locales exist, from the app: a localized field is offered to the filter panel as its locale paths.\n */\n locales?: readonly string[]\n /** Every kayan of the build, so a relationship's hop can name the target's fields. */\n client?: readonly ClientKayan[]\n /**\n * The typed client's reads. A relationship column names the rows it points at with one read per target for the whole page, and the filter panel's relationship value input pages its target with it.\n */\n api?: FindApi\n /**\n * Whether this actor may read one field, off `GET /api/access`. Chrome only: the read masks a denied column and refuses a denied filter whatever this says.\n */\n reads?: (kayan: string, field: string) => boolean\n /**\n * Whether this actor may create a row of this kayan, off the same report. Chrome only: the create is authorized server-side. Undeclared is allowed.\n */\n creates?: boolean\n /**\n * This actor's answer per operation name, off the same report. A button in the list node's `toolbar`, `bulk` or `row` is drawn where it allows that operation, and the selection column where it allows a bulk button. Chrome only; an absent key is denied.\n */\n operations?: Readonly<Record<string, boolean>>\n /** Whether this actor may write one field on an update, for a bulk update's boxes. */\n writes?: (field: string) => boolean\n /**\n * The access report a list button's `when` reads as `$user`, cut to the screen's kayan.\n */\n access?: unknown\n /**\n * What running one of the list's buttons costs the host: the call and the re-read after it. With none, no button is drawn, as there would be nothing for it to do.\n */\n run?: (operation: string, input: Record<string, unknown>) => Promise<unknown>\n /** Where the next query goes. Navigation, as a callback. */\n onQuery: (query: Query) => void\n /** Where one row goes. Navigation, as a prop, so no router is imported. */\n href: (id: string) => string\n /** Where a new row is written. */\n newHref: string\n /**\n * A screen's list page, where this list draws one: its list node names the columns, filters and search, and the components it names by path are drawn from here.\n */\n screen?: ScreenPage\n /** The list node this list draws, where it is not the page's own: a list of another kayan. */\n node?: ListNode\n /**\n * How a list of another kayan edits its rows in place, below the table: one row at a time, in the row's own form over the list's columns, a create being the same form with the page's row already in its relationship. Every write is the listed kayan's own and saves apart from the page's form. A row still opens its own page through `href`.\n */\n edit?: Pick<FormProps, 'client' | 'writes'> & {\n /** What every row it adds holds whatever the form sends: the relationship to the page's row. */\n prefill?: Readonly<Record<string, unknown>>\n }\n}\n\n/**\n * Which columns a list shows, in order: the URL's list when it carries one, else the list node's `columns`, else every field. Never a field this actor may not read.\n */\nexport const columnsOf = ({\n kayan,\n cols,\n reads,\n opens,\n}: {\n kayan: ClientKayan\n cols?: readonly string[]\n reads?: (kayan: string, field: string) => boolean\n /** The columns a page's list node opens with, which is every field where there is no node. */\n opens?: readonly string[]\n}): readonly ColumnChoice[] => {\n const isDefault = (_kayan: ClientKayan, field: ClientField) => !opens || opens.includes(field.name)\n const candidates = columnCandidates(kayan, reads)\n const offered = new Set(candidates.map((field) => field.name))\n const fromUrl = columnsFrom(cols).filter((one) => offered.has(one.name))\n const named = new Set(fromUrl.map((one) => one.name))\n const rest = candidates.filter((field) => !named.has(field.name))\n if (fromUrl.length) {\n // A column the URL did not name keeps the declaration's answer, so a field added to a kayan is not hidden from old links.\n return [...fromUrl, ...rest.map((field) => ({ name: field.name, active: isDefault(kayan, field) }))]\n }\n return candidates.map((field) => ({ name: field.name, active: isDefault(kayan, field) }))\n}\n\n/**\n * The query a column change asks for. It keeps the page it is on, since it changes what a row shows and never which rows match.\n */\nexport const withColumns = (query: Query, columns: readonly ColumnChoice[]): Query => ({\n ...query,\n cols: columnsTo(columns),\n})\n\n// Pinned cells are opaque (the card's fill with the head's or hovered row's tint mixed in), because a translucent one\n// shows the cells that scroll beneath it.\nconst headFill = 'bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]'\nconst pinnedHead = `sticky z-10 ${headFill}`\nconst pinned = 'sticky z-10 bg-card group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]'\n// A shadow on the row menu column's start edge while the table runs on under it. The head cell keeps its rule underneath\n// (`[&_th]:shadow-[inset_0_-1px_0_var(--border)]` on the head row), so the head's own takes `!` and carries the rule too.\nconst edge = 'shadow-[-10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)] rtl:shadow-[10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)]'\nconst edgeHead = 'shadow-[inset_0_-1px_0_var(--border),-10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)]! rtl:shadow-[inset_0_-1px_0_var(--border),10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)]!'\n// Exactly a touch target wide, so the first column pins right after it; on a phone as tall, so the box's 44 pixel layer\n// stays inside its own row.\nconst selection = 'w-11 min-w-11 p-0 max-sm:h-11 [&>*]:mx-auto [&>[role=checkbox]]:translate-y-0'\n\nconst nextSort = (name: string, sort: string | undefined) =>\n sort === name ? `-${name}` : sort === `-${name}` ? undefined : name\n\nfunction Search({\n fields,\n locale,\n onSearch,\n t,\n value,\n}: {\n value: string | undefined\n /** The labels of the fields a search runs over, which is empty for a kayan with none. */\n fields: readonly string[]\n locale: string\n onSearch: (term: string | undefined) => void\n t: Translate\n}): ReactElement | null {\n const [text, setText] = useState(value ?? '')\n useEffect(() => setText(value ?? ''), [value])\n useEffect(() => {\n if (text === (value ?? '')) return\n const timer = setTimeout(() => onSearch(text.trim() === '' ? undefined : text), 300)\n return () => clearTimeout(timer)\n }, [onSearch, text, value])\n // No searchable field, no box. After the hooks, which must run on every render or on none.\n if (!fields.length) return null\n const label = t('admin.search', 'Search') ?? 'Search'\n const placeholder = new Intl.ListFormat(locale, { type: 'disjunction' }).format(fields)\n return (\n <span className=\"flex w-full items-center gap-1 lg:max-w-sm\">\n <span className=\"relative w-full\">\n <SearchIcon className=\"text-muted-foreground absolute start-3 top-1/2 size-4 -translate-y-1/2\" aria-hidden=\"true\" />\n <Input\n type=\"search\"\n className=\"ps-9 max-sm:h-11\"\n placeholder={placeholder}\n aria-label={label}\n value={text}\n onChange={(event) => setText(event.target.value)}\n />\n </span>\n {text ? (\n <Button type=\"button\" variant=\"outline\" size=\"icon-sm\" className=\"max-sm:size-11\" onClick={() => setText('')} aria-label={t('admin.clearSearch', 'Clear search') ?? 'Clear search'}>\n <X />\n </Button>\n ) : null}\n </span>\n )\n}\n\nexport function List({\n access,\n api,\n client,\n creates,\n edit,\n href,\n kayan,\n locale,\n locales,\n newHref,\n onQuery,\n operations,\n page,\n query,\n reads,\n run,\n screen,\n t,\n writes,\n ...props\n}: ListProps): ReactElement {\n const copy: Copy = { t, locale }\n const { maxLimit } = useScreen()\n const node = props.node ?? listOf(screen)\n /** The row being edited in place, `null` for a row being added, and nothing while no form is open. */\n const [editing, setEditing] = useState<Row | null | undefined>(undefined)\n const edits = edit && node\n ? { ...edit, client: { ...edit.client, create: (input: Record<string, unknown>) => edit.client.create({ ...input, ...edit.prefill }) } }\n : undefined\n const Heading = node?.of ? 'h2' : 'h1'\n const Empty = refOf({ screen, place: 'empty', path: node?.empty })\n /** The project's items in each row's menu, and the dialog one of them opened, which outlives the menu. */\n const Menu = refOf({ screen, place: 'menu', path: node?.menu })\n const [over, setOver] = useState<ReactNode>(null)\n const cellOf = new Map(node?.columns.map((column) => [column.field, column.cell]))\n const searched = searchableFields({ kayan, search: node?.search, client })\n const columns = columnsOf({ kayan, cols: query.cols, reads, opens: node?.columns.map((column) => column.field) })\n const active = columns.flatMap((column) => kayan.fields.find((field) => field.name === column.name && column.active) ?? [])\n const titled = active.find((field) => field.name === kayan.useAsTitle)\n const shown = titled ? [titled, ...active.filter((field) => field !== titled)] : active\n // One read per target for the whole page, so a relationship column is not one read per cell. Over the shown columns\n // alone, so a relationship the viewer has switched off costs no read.\n const titles = useRelationshipTitles({ fields: shown, docs: page.docs, context: { api, client, locale } })\n const paths = filterPathsOf(kayan, { client, locales, reads, ...(node?.filters && { filters: node.filters }) })\n // The first shown cell carries the row's link, so a hidden or unreadable title leaves a row reachable.\n const linked = shown[0]\n const refine = (next: Partial<Query>) => onQuery(refined(query, next))\n const filtered = Boolean(query.where || query.search)\n /**\n * Whether the person has opened or closed the filter panel, and nothing until they do. Until then it is open on a list whose URL filters, read on every render so a `where` that arrives after mount opens it.\n */\n const [filtering, setFiltering] = useState<boolean | undefined>(undefined)\n const open = filtering ?? Boolean(query.where)\n const filtersId = useId()\n /** The kayan's plural, which the bulk dialog and its result name the ticked rows by. */\n const plural = titleOf({ kayan, copy, many: true })\n const about = descriptionOf(kayan, copy)\n\n /**\n * The list node's buttons this actor may run, drawn only where the host said what running one costs and the report allows its operation by name.\n */\n const allowed = (declared: readonly ButtonClient[] | undefined) =>\n run ? (declared ?? []).flatMap((node) => {\n const operation = pageButtonOf({ kayan, node, allowed: operations })\n return operation ? [{ node, operation }] : []\n }) : []\n /** What a toolbar or bulk button's `when` reads: no values and no row, and the report as `$user`. */\n const scope = scopeOf({ values: {}, record: null, access, kayan })\n const toolbar = allowed(node?.toolbar).filter((one) => holds(one.node, scope))\n // A bulk button's boxes are the row's fields it names, cut to those this actor could write; its call is the ticked\n // rows' `where` around them.\n const bulkButtons = allowed(node?.bulk).filter((one) => holds(one.node, scope)).map((one) => ({\n ...one,\n operation: { ...one.operation, fields: writableFields(kayan, writes).filter((field) => one.node.fields?.includes(field.name)) },\n }))\n const rowButtons = allowed(node?.row)\n const editsRows = edits !== undefined && operations?.update === true\n const deletes = run !== undefined && operations?.delete === true && kayan.operations.some((one) => one.name === 'delete')\n const [deleting, setDeleting] = useState<Row | undefined>(undefined)\n const [asking, setAsking] = useState<{ row: Row; operation: PageButton } | undefined>(undefined)\n const [running, setRunning] = useState(false)\n const table = useRef<HTMLTableElement>(null)\n // Whether the table runs past its frame's end, so the frame fades there. The value is the width of the row menu's pinned\n // column, which the fade sits before, and `false` where nothing runs past. `scrollLeft` is negative on a right-to-left page.\n const [more, setMore] = useState<number | false>(false)\n useEffect(() => {\n const grid = table.current\n const frame = grid?.parentElement\n if (!grid || !frame) return\n const check = () => setMore(frame.scrollWidth - frame.clientWidth - Math.abs(frame.scrollLeft) > 1 && (grid.querySelector('thead th:last-child')?.getBoundingClientRect().width ?? 0))\n check()\n frame.addEventListener('scroll', check, { passive: true })\n const resized = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(check)\n resized?.observe(frame)\n resized?.observe(grid)\n return () => {\n frame.removeEventListener('scroll', check)\n resized?.disconnect()\n }\n }, [])\n /**\n * The name of a row's menu button, naming the row by its title so a reader going down the column hears each row's own; a row with no title, or with the title column hidden, keeps the shared name.\n */\n const rowActionsLabel = (row: Row) => {\n const name = titled && rowTitle({ kayan, row, locale })\n // Bidi-isolated, so a name in the other direction does not reorder the sentence.\n return name ? (t('admin.rowActionsFor', 'Actions for {name}') ?? 'Actions for {name}').replace('{name}', () => `\\u2068${name}\\u2069`) : (t('admin.rowActions', 'Row actions') ?? 'Row actions')\n }\n const openHint = useId()\n /**\n * Opens a row by clicking its own link, so the host routes it as it routes every other link and the list imports no router.\n */\n const openRow = (id: string) =>\n [...(table.current?.querySelectorAll<HTMLAnchorElement>('a[data-row-link]') ?? [])].find((one) => one.dataset.rowLink === id)?.click()\n const runOnRow = async (operation: string, input: Record<string, unknown>, refuse: (thrown: unknown) => void = setRefused) => {\n setRunning(true)\n setRefused(undefined)\n try {\n await run!(operation, input)\n return true\n } catch (thrown) {\n refuse(thrown)\n return false\n } finally {\n setRunning(false)\n }\n }\n /**\n * Whether no column the list opens with is one the viewer may read, which the projection says by leaving the field out. Such a list draws no rows, nor anything that acts on them.\n */\n const unreadable = !(node?.columns.map((column) => column.field) ?? kayan.fields.map((field) => field.name)).some((name) => kayan.fields.some((field) => field.name === name))\n const picks = bulkButtons.length > 0 && !unreadable\n /**\n * The refusal the last run answered with, drawn above the table, so a refused button that closed does not leave the page reading as changed.\n */\n const [refused, setRefused] = useState<unknown>(undefined)\n const refusal = refusalKeyOf(refused)\n const button = (one: PageButton, inputOf: (values: Record<string, unknown>) => Record<string, unknown>, key: string, extra: { description?: string; disabled?: boolean; onRan?: (written: unknown) => void } = {}) => (\n <ListButton key={key} operation={one} kayan={kayan} client={client} api={api} copy={copy} screen={screen} run={run!} inputOf={inputOf} onRefused={setRefused} {...extra} />\n )\n const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set())\n /**\n * How many rows the last bulk changed, announced in the count's live region after the ticks drop. A new object per bulk restarts its six seconds; it clears after them or when a row is ticked again.\n */\n const [done, setDone] = useState<{ count: number } | undefined>(undefined)\n useEffect(() => {\n if (!done) return\n const timer = setTimeout(() => setDone(undefined), 6000)\n return () => clearTimeout(timer)\n }, [done])\n const rowIds = page.docs.map((row) => String(row.id ?? ''))\n /**\n * The ticked rows still on the page. A page that moved under the selection leaves ids that are no longer on screen, and a delete of rows nobody can see is the one thing this must not send.\n */\n const ticked = rowIds.filter((one) => one && selected.has(one))\n useEffect(() => {\n if (ticked.length) setDone(undefined)\n }, [ticked.length])\n /** A counted sentence about the ticked rows, capitalised though the kayan's plural may be lower case. */\n const aboutTicked = (key: string, count: number, fallback: string) => {\n const said = counted({ t, locale, key, count, fallback }).replace('{plural}', () => plural)\n return said.charAt(0).toLocaleUpperCase(locale) + said.slice(1)\n }\n\n // Per-viewer limit and columns are remembered in `localStorage`, read once and only where the URL named neither.\n // Read in an effect: it does not exist on the server, and reading it during render would mismatch hydration.\n useEffect(() => {\n if (query.limit !== undefined || query.cols !== undefined) return\n const held = remembered(kayan.name, maxLimit)\n if (held.limit === undefined && held.cols === undefined) return\n onQuery({ ...query, ...held })\n // The kayan alone: running again on the query would undo a viewer's own change back to what was remembered.\n }, [kayan.name])\n useEffect(() => {\n if (query.limit === undefined && query.cols === undefined) return\n remember(kayan.name, { ...(query.limit !== undefined && { limit: query.limit }), ...(query.cols !== undefined && { cols: query.cols }) })\n }, [kayan.name, query.cols, query.limit])\n\n return (\n <section className={`flex w-full min-w-0 flex-1 flex-col gap-4 ${ticked.length || done ? 'max-sm:pb-[calc(5rem+env(safe-area-inset-bottom))]' : ''}`}>\n {/* `flex-wrap`: on a phone the title and the button share 320 pixels. `min-w-0` on the section lets the list shrink\n inside the grid or flex row that holds it, whose items otherwise keep the width of their widest row. */}\n <header className=\"flex flex-wrap items-start justify-between gap-4\">\n <div className=\"grid min-w-0 gap-1\">\n {/* A title node on the screen's own list page is the page's one h1, so the list keeps only its sentence; a list\n of another kayan keeps its heading. */}\n {screen && !node?.of && drawsKind({ nodes: nodesOf(screen.page), kind: 'title', scope }) ? null : <Heading className={node?.of ? 'text-lg font-semibold' : 'text-2xl font-bold tracking-tight'}>{titleOf({ kayan, copy, many: true })}</Heading>}\n {about ? <p className=\"text-muted-foreground text-sm\">{about}</p> : null}\n </div>\n {creates === false ? null : edits ? (\n // A list of another kayan adds a row in place, below the table.\n <Button type=\"button\" className=\"max-sm:h-11\" onClick={() => setEditing(null)}>\n <Plus className=\"me-2 h-4 w-4\" />\n {t(`${kayan.name}.new`, t('admin.new', 'New'))}\n </Button>\n ) : (\n // `render` keeps the destination an anchor; `nativeButton={false}` tells base-ui the rendered element is not\n // a button.\n <Button nativeButton={false} className=\"max-sm:h-11\" render={<a href={newHref} />}>\n <Plus className=\"me-2 h-4 w-4\" />\n {t(`${kayan.name}.new`, t('admin.new', 'New'))}\n </Button>\n )}\n </header>\n\n <Card>\n <CardContent>\n <div className=\"w-full space-y-4\">\n <div className=\"flex flex-col justify-between gap-2 lg:flex-row lg:items-center\">\n <div className=\"flex flex-1 flex-col gap-2 lg:flex-row lg:items-center\">\n <Search\n value={query.search}\n fields={searched.flatMap((path) => {\n // A path across a relationship is named by both labels (\"Course Title\"), so the far field is not\n // mistaken for the list's own, in the locale's order.\n const [own, far] = path.split('.')\n const near = kayan.fields.find((one) => one.name === own)\n const there = far === undefined ? kayan : client?.find((one) => one.name === near?.relationTo)\n const field = there?.fields.find((one) => one.name === (far ?? own))\n if (!there || !field) return []\n const label = labelFor({ kayan: there, field, copy })\n if (far === undefined || !near) return [label]\n return [(t('admin.searchThrough', '{relation} {field}') ?? '{relation} {field}').replace('{relation}', () => labelFor({ kayan, field: near, copy })).replace('{field}', () => label)]\n })}\n locale={locale}\n t={t}\n onSearch={(search) => refine({ search })}\n />\n </div>\n <div className=\"flex flex-wrap items-center gap-2\">\n {refusal ? (\n <p role=\"alert\" className=\"rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive\">\n {t(refusal, refusal)}\n </p>\n ) : null}\n {toolbar.map((one, at) => button(one.operation, (values) => values, `toolbar-${at}`, { description: t('admin.toolbarWarning', 'This runs on no row of the list.') ?? 'This runs on no row of the list.' }))}\n {/* Both stay mounted, the buttons hidden until a row is ticked: a live region must exist before its first change\n to be announced, and a dialog a button opened must outlive a read that took the ticked rows off the page. */}\n {picks ? (\n // On a phone the count and bulk buttons are a bar fixed to the bottom while a row is ticked, so ticking\n // does not push the table down under the finger. It stands on the form's own bar where a record page draws one.\n <div\n data-mithaq-bulk-bar=\"\"\n role=\"region\"\n aria-label={t('admin.bulkBar', 'Ticked rows') ?? 'Ticked rows'}\n className={ticked.length || done ? 'contents max-sm:bg-background max-sm:fixed max-sm:inset-x-0 max-sm:bottom-[var(--action-bar-height,0px)] max-sm:z-30 max-sm:flex max-sm:flex-nowrap max-sm:items-center max-sm:gap-2 max-sm:overflow-x-auto max-sm:border-t max-sm:p-3 max-sm:pb-[max(0.75rem,env(safe-area-inset-bottom))] max-sm:[&_button]:shrink-0 max-sm:[&>span]:shrink-0' : 'contents'}\n >\n {/* `aria-live`: the count changes as boxes are ticked and nothing else announces it. */}\n <span className={ticked.length || done ? 'text-muted-foreground text-sm' : 'sr-only'} aria-live=\"polite\" data-mithaq-selected={ticked.length || undefined} data-mithaq-done={!ticked.length && done ? done.count : undefined}>\n {ticked.length\n ? (t('admin.selected', '{count} selected') ?? '{count} selected').replace('{count}', String(ticked.length))\n : done ? aboutTicked('admin.bulkDone', done.count, '{plural} done: {count}.') : ''}\n </span>\n <span hidden={ticked.length === 0} className={ticked.length ? 'contents' : undefined}>\n {bulkButtons.map((one, at) =>\n button(\n one.operation,\n // Only the ticked rows still on the page, with the boxes' values as the data written over each.\n (values) => ({ where: { id: { in: ticked } }, ...(one.operation.fields.length > 0 && { data: values }) }),\n `bulk-${at}`,\n {\n // A where of no id is a call nobody ticked, which a dialog left open over rows that left the\n // page would confirm, so it is disabled.\n disabled: ticked.length === 0,\n description: aboutTicked('admin.bulkWarning', ticked.length, '{plural} you ticked: {count}. This applies to each of them.'),\n // The ticked ids name rows the host's re-read is about to drop, so the selection is cleared\n // and what the bulk did is said, less the rows it named as left.\n onRan: (written) => {\n setDone({ count: ticked.length - ((written as { errors?: readonly unknown[] } | null)?.errors?.length ?? 0) })\n setSelected(new Set())\n },\n },\n ),\n )}\n </span>\n </div>\n ) : null}\n {paths.length ? (\n <Button\n type=\"button\"\n variant={open || query.where ? 'secondary' : 'outline'}\n className=\"max-sm:h-11\"\n aria-controls={open ? filtersId : undefined}\n aria-expanded={open}\n onClick={() => setFiltering(!open)}\n >\n {t('admin.filters', 'Filters') ?? 'Filters'}\n <ChevronDown className={open ? 'rotate-180' : undefined} aria-hidden=\"true\" />\n </Button>\n ) : null}\n {unreadable ? null : (\n <Columns\n kayan={kayan}\n columns={columns}\n t={t}\n locale={locale}\n onChange={(next) => onQuery(withColumns(query, next))}\n />\n )}\n </div>\n </div>\n\n {open ? (\n <div id={filtersId}>\n <Filters\n paths={paths}\n value={query.where}\n t={t}\n locale={locale}\n client={client}\n api={api}\n onChange={(where: FilterWhere | undefined) => {\n // The person is at work in the panel, so it stays, even where the last filter goes.\n setFiltering(true)\n refine({ where })\n }}\n />\n </div>\n ) : null}\n\n {/* Held to the screen below the page's header so it scrolls both ways and the head row can stay (a printed page\n holds every row). It isolates, so pinned cells never paint over the page's sticky header or take its clicks. */}\n <div className=\"bg-card relative isolate overflow-hidden rounded-lg border [&>div:first-child]:max-h-[calc(100dvh-var(--header-height,0px))] print:[&>div:first-child]:max-h-none\">\n {unreadable ? null : (\n <Table ref={table}>\n {/* The head rule is each head cell's own inset shadow, since a collapsed border scrolls away from a sticky\n head; the row's own `border-b` is dropped so both do not draw. */}\n <TableHeader className={`sticky top-0 z-20 ${headFill} [&_tr]:border-b-0 [&_th]:shadow-[inset_0_-1px_0_var(--border)]`}>\n <TableRow>\n {picks ? (\n <TableHead scope=\"col\" className={`${pinnedHead} start-0 ${selection}`}>\n <SelectAll ids={rowIds} selected={selected} onSelect={setSelected} t={t} />\n </TableHead>\n ) : null}\n {shown.map((field) => {\n // A heading is a button only where the read would take the sort: the server answers 400 for a\n // localized field, a set, a join and a screen's `sortable: false`.\n const sortable = canSort(field)\n // The URL's sort, else the list node's default, which is the order the page was read in.\n const sort = query.sort ?? node?.sort\n const direction = sort === field.name ? 'ascending' : sort === `-${field.name}` ? 'descending' : 'none'\n return (\n <TableHead key={field.name} scope=\"col\" aria-sort={sortable ? direction : undefined} className={field === linked ? `${pinnedHead} ${picks ? 'start-11' : 'start-0'}` : undefined}>\n {sortable ? (\n // One arrow, on the sorted column only. `aria-hidden` on the glyph and `aria-sort` on the\n // cell keep the answer readable without it.\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"-ms-2 h-8 px-2 max-sm:h-11 max-sm:min-w-11\"\n onClick={() => refine({ sort: nextSort(field.name, query.sort) })}\n >\n {labelFor({ kayan, field, copy })}\n {direction === 'ascending' ? (\n <ArrowUp className=\"size-4\" aria-hidden=\"true\" />\n ) : direction === 'descending' ? (\n <ArrowDown className=\"size-4\" aria-hidden=\"true\" />\n ) : null}\n </Button>\n ) : (\n labelFor({ kayan, field, copy })\n )}\n </TableHead>\n )\n })}\n <TableHead scope=\"col\" className={`${pinnedHead} end-0 w-0 ${more === false ? '' : edgeHead}`}>\n <span className=\"sr-only\">{t('admin.actions', 'Actions')}</span>\n </TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {page.docs.map((row, index) => {\n const id = String(row.id ?? '')\n // The list node's row buttons whose `when` holds over this row. One a project draws with its own\n // component stays in the cell, since a menu item cannot be one.\n const offered = rowButtons.filter((one) => holds(one.node, scopeOf({ values: row, record: row, access, kayan })))\n const own = offered.filter((one) => refOf({ screen, place: 'button', path: one.operation.component }))\n const menu = offered.filter((one) => !own.includes(one))\n // `#` is the host's word for a kayan with no page that draws a row, so such a row is no link and\n // no tab stop, and its menu offers neither Edit nor View.\n const to = href(id)\n const opens = to !== '#'\n const edits = editsRows || opens\n return (\n // The whole row opens the record, and so does Enter on the row. A click on anything inside that acts\n // on its own, or on the menu or a dialog it opened (React hands those up through its portal), opens nothing.\n <TableRow\n key={String(row.id ?? index)}\n tabIndex={opens ? 0 : undefined}\n data-row={id}\n aria-describedby={opens ? openHint : undefined}\n // The ring every focusable control draws, inset so the table's frame does not clip it.\n className={opens ? 'group/row cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset' : 'group/row'}\n onClick={(event: MouseEvent<HTMLTableRowElement>) => {\n const target = event.target as Element\n if (!opens || !event.currentTarget.contains(target) || target.closest('a, button, input, select, textarea, label, [role=\"checkbox\"], [role=\"menuitem\"]')) return\n openRow(id)\n }}\n onKeyDown={(event: KeyboardEvent<HTMLTableRowElement>) => {\n if (!opens || event.key !== 'Enter' || event.target !== event.currentTarget) return\n event.preventDefault()\n openRow(id)\n }}\n >\n {picks ? (\n <TableCell className={`${pinned} start-0 ${selection}`}>\n <SelectRow id={id} selected={selected} onSelect={setSelected} t={t} />\n </TableCell>\n ) : null}\n {shown.map((field) => {\n const Ref = refOf({ screen, place: 'cell', path: cellOf.get(field.name) ?? field.cell })\n // An email cell is a mail link, and a link cannot sit in the row's link (`<a>` in `<a>` is a\n // hydration error), so the linked column draws its text. Upload draws no anchor.\n const drawn = cellFor(field, cellOf.get(field.name))\n const Cell = drawn === cells.email && field === linked && opens ? cells.text! : drawn\n const untitled = field.name === kayan.useAsTitle && isUntitled({ kayan, row, locale })\n const cell = untitled ? (\n <span className=\"text-muted-foreground\">{t('admin.untitled', 'Untitled') ?? 'Untitled'}</span>\n ) : Ref && screen ? (\n <Ref field={field} value={row[field.name]} row={row} />\n ) : (\n <Cell kayan={kayan} field={field} value={row[field.name]} t={t} locale={locale} titles={titles} />\n )\n return (\n <TableCell key={field.name} className={field === linked ? `${pinned} ${picks ? 'start-11' : 'start-0'}` : undefined}>\n {field === linked && opens ? (\n // A real link, so it opens in a new tab and lists for a reader, but out of the tab\n // order since the row is the stop. An untitled row's name holds the visible text.\n <a href={to} data-row-link={id} tabIndex={-1} aria-label={untitled ? (t('admin.openUntitled', 'Untitled, open') ?? 'Untitled, open') : undefined} className=\"font-medium hover:underline\">\n {cell}\n </a>\n ) : (\n cell\n )}\n </TableCell>\n )\n })}\n <TableCell className={`${pinned} end-0 w-0 ${more === false ? '' : edge}`}>\n <div className=\"flex items-center justify-end gap-2\">\n {own.map((one, at) => button(one.operation, (values) => ({ ...values, id: row.id }), `row-${at}`, { description: rowWarning({ kayan, row, operation: one.operation.name, copy }) }))}\n {edits || menu.length || deletes || Menu ? (\n <DropdownMenu>\n <DropdownMenuTrigger\n data-row-menu={id}\n aria-label={rowActionsLabel(row)}\n className={buttonVariants({ variant: 'ghost', size: 'icon-sm', className: 'max-sm:size-11' })}\n >\n <MoreHorizontal className=\"size-4\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-40\">\n {/* View where the report lets this actor update no row of the kayan, since the row's\n page is then only read. */}\n {edits ? (\n operations && operations.update !== true ? (\n <DropdownMenuItem data-row-action=\"view\" className=\"max-sm:min-h-11\" onClick={() => openRow(id)}>\n {t('admin.view', 'View') ?? 'View'}\n </DropdownMenuItem>\n ) : (\n <DropdownMenuItem data-row-action=\"edit\" className=\"max-sm:min-h-11\" onClick={() => (editsRows ? setEditing(row) : openRow(id))}>\n {t('admin.edit', 'Edit') ?? 'Edit'}\n </DropdownMenuItem>\n )\n ) : null}\n {menu.map((one) => (\n <DropdownMenuItem\n key={one.operation.name}\n data-row-action={one.operation.name}\n className=\"max-sm:min-h-11\"\n disabled={running}\n onClick={() => {\n // A button that asks for values or carries a confirm opens its dialog over the\n // row; any other runs.\n if (one.operation.fields?.length || one.operation.confirm) return setAsking({ row, operation: one.operation })\n void runOnRow(one.operation.name, { id: row.id })\n }}\n >\n {operationLabelFor({ kayan, operation: one.operation, copy })}\n </DropdownMenuItem>\n ))}\n {Menu ? <Menu row={row} open={setOver} /> : null}\n {deletes ? (\n <DropdownMenuItem data-row-action=\"delete\" variant=\"destructive\" className=\"max-sm:min-h-11\" disabled={running} onClick={() => setDeleting(row)}>\n {t('admin.delete', 'Delete') ?? 'Delete'}\n </DropdownMenuItem>\n ) : null}\n </DropdownMenuContent>\n </DropdownMenu>\n ) : null}\n </div>\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </Table>\n )}\n {/* The table can be wider than a phone, so a sentence in a cell spanning it would be cut off at the frame's\n edge. */}\n {page.docs.length === 0 ? (\n <div data-list-message=\"\" className=\"text-muted-foreground flex min-h-24 items-center justify-center p-4 text-center text-sm\">\n {filtered ? (\n <span className=\"flex flex-col items-center gap-2\">\n {t('admin.noMatches', 'No rows match this filter.') ?? 'No rows match this filter.'}\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"max-sm:h-11\" onClick={() => refine({ search: undefined, where: undefined })}>\n {t('admin.clearFilters', 'Clear filters') ?? 'Clear filters'}\n </Button>\n </span>\n ) : Empty && screen ? (\n <Empty page={screen.page} />\n ) : (\n (t(`${kayan.name}.none`) ?? t('admin.empty', 'Nothing here yet.') ?? 'Nothing here yet.')\n )}\n </div>\n ) : unreadable ? (\n // Rows with no column to draw would read as records with nothing in them, so the list says what it\n // cannot show, and whom to ask\n <div data-list-message=\"\" className=\"text-muted-foreground flex min-h-24 items-center justify-center p-4 text-center text-sm\">\n {t('admin.noColumns', 'Your role can\\'t see any column of this list. Ask an administrator of your organization for access.')}\n </div>\n ) : null}\n {more === false ? null : <div data-scroll-cue=\"\" aria-hidden className=\"from-card pointer-events-none absolute inset-y-0 w-10 bg-linear-to-l to-transparent rtl:bg-linear-to-r\" style={{ insetInlineEnd: more }} />}\n </div>\n\n <span id={openHint} hidden>\n {t('admin.openRow', 'Press Enter to open this row.') ?? 'Press Enter to open this row.'}\n </span>\n\n {/* Drawn once below the table rather than inside the menu, which closes as its item is pressed and would\n take a dialog inside it along. */}\n {deletes ? (\n <DeleteButton\n busy={running}\n copy={copy}\n kayan={kayan}\n label={t('admin.delete', 'Delete') ?? 'Delete'}\n open={deleting !== undefined}\n onOpenChange={(open) => (open ? undefined : setDeleting(undefined))}\n row={deleting}\n onDelete={(refuse) => runOnRow('delete', { id: deleting?.id }, refuse)}\n />\n ) : null}\n {asking ? (\n <OperationButton\n api={api}\n busy={running}\n copy={copy}\n description={rowWarning({ kayan, row: asking.row, operation: asking.operation.name, copy })}\n inputOf={(values) => ({ ...values, id: asking.row.id })}\n kayan={kayan}\n kayans={client}\n operation={asking.operation}\n open\n onOpenChange={(open) => (open ? undefined : setAsking(undefined))}\n run={(input, refuse) => runOnRow(asking.operation.name, input, refuse)}\n />\n ) : null}\n {over}\n\n {unreadable ? null : (\n <Pagination\n page={page}\n t={t}\n locale={locale}\n onPage={(next) => onQuery({ ...query, page: next })}\n onLimit={(limit) => refine({ limit })}\n />\n )}\n\n {edits && editing !== undefined ? (\n <div data-mithaq-inline-edit={editing ? String(editing.id ?? '') : 'new'} className=\"bg-muted/30 rounded-md border p-3\">\n <Form\n kayan={kayan}\n screen={screen && { ...screen, kayan }}\n nodes={node!.columns}\n client={edits.client}\n writes={edits.writes}\n value={editing}\n t={t}\n locale={locale}\n locales={locales}\n kayans={client}\n api={api}\n onDone={() => setEditing(undefined)}\n onCancel={() => setEditing(undefined)}\n />\n </div>\n ) : null}\n </div>\n </CardContent>\n </Card>\n </section>\n )\n}\n\nfunction ListButton({\n api,\n client,\n copy,\n description,\n disabled,\n inputOf,\n kayan,\n onRan,\n onRefused,\n operation,\n run,\n screen,\n}: {\n api?: FindApi\n client?: readonly ClientKayan[]\n copy: Copy\n description?: string\n disabled?: boolean\n inputOf: (values: Record<string, unknown>) => Record<string, unknown>\n kayan: ClientKayan\n onRan?: (written: unknown) => void\n onRefused: (thrown: unknown) => void\n operation: PageButton\n run: (operation: string, input: Record<string, unknown>) => Promise<unknown>\n screen?: ScreenPage\n}): ReactElement {\n const [busy, setBusy] = useState(false)\n const call = async (input: Record<string, unknown>, refuse: (thrown: unknown) => void = onRefused) => {\n // Every path ends here, so a button that ignores `busy` sends nothing too.\n if (disabled) return false\n setBusy(true)\n onRefused(undefined)\n try {\n onRan?.(await run(operation.name, input))\n return true\n } catch (thrown) {\n refuse(thrown)\n return false\n } finally {\n setBusy(false)\n }\n }\n const Own = refOf({ screen, place: 'button', path: operation.component })\n if (Own && screen) {\n return <OwnButton Own={Own} operation={operation} busy={busy || disabled === true} copy={copy} call={async (input: Row) => void (await call(inputOf(input)))} />\n }\n if (!operation.fields?.length) return <NodeButton node={operation} copy={copy} busy={busy || disabled === true} description={description} run={() => void call(inputOf({}))} />\n return (\n <OperationButton\n api={api}\n busy={busy || disabled === true}\n copy={copy}\n description={description}\n inputOf={inputOf}\n kayan={kayan}\n kayans={client}\n operation={operation}\n run={call}\n />\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 97acf94d409745118e2ec5a6f107c4ef0ff08f4ec9202ba97fb24f8408de7ddf\n",
28
+ "type": "registry:component",
29
+ "target": "@components/mithaq/list.tsx"
30
+ }
31
+ ],
32
+ "type": "registry:component"
33
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "mithaq-nav",
4
+ "title": "Mithaq nav",
5
+ "description": "The admin's app shell: the inset sidebar with the organization switcher, the kayan index the access report allows, the user menu, and the header with the sidebar trigger and the theme toggle.",
6
+ "registryDependencies": [
7
+ "avatar",
8
+ "button",
9
+ "dropdown-menu",
10
+ "separator",
11
+ "sheet",
12
+ "sidebar",
13
+ "@mithaq/mithaq-controls"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "registry/nav.tsx",
18
+ "content": "import { createContext, useContext, useEffect, useMemo, useState, type ComponentProps, type CSSProperties, type ReactNode } from 'react'\nimport {\n BookOpen,\n Building2,\n CalendarDays,\n Check,\n ChevronsUpDown,\n ClipboardList,\n Folder,\n GalleryVerticalEnd,\n HelpCircle,\n Languages,\n LayoutGrid,\n LogOut,\n Mail,\n Monitor,\n Moon,\n MoreHorizontal,\n PlusCircle,\n Search,\n Settings2,\n Share2,\n ShieldCheck,\n Sun,\n Table2,\n Trash2,\n Users,\n type LucideIcon,\n} from 'lucide-react'\nimport { Moon02Icon, Sun03Icon } from '@hugeicons/core-free-icons'\nimport { HugeiconsIcon } from '@hugeicons/react'\nimport type { ClientKayan } from 'mithaq/admin'\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar'\nimport { Button, buttonVariants } from '@/components/ui/button'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { Separator } from '@/components/ui/separator'\nimport { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'\nimport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarHeader,\n SidebarInset,\n SidebarMenu,\n SidebarMenuAction,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarProvider,\n SidebarTrigger,\n useSidebar,\n} from '@/components/ui/sidebar'\nimport { titleOf, type Translate } from './controls.tsx'\n\n/**\n * As much of the access report as this screen reads: one `read` per kayan, keyed by its name.\n * `operations` is what a screen gates a control on: an effect is allowed where any one of its\n * operations is, so gate a button on `operations.archive`, never on the effect.\n */\nexport type AccessReport = {\n readonly collections?: Readonly<\n Record<\n string,\n { readonly read?: boolean; readonly operations?: Readonly<Record<string, boolean>> } | undefined\n >\n >\n}\n\n/**\n * The two keys of a kayan this screen reads: its name, and whether it is a global, which picks the\n * singular over the plural. A shell handed only these server-renders no field names or labels.\n */\nexport type NavKayan = Pick<ClientKayan, 'name' | 'single'>\n\n/**\n * One lucide icon per common kayan name, `Table2` for every other kayan. An app gives its own\n * kayan an icon by editing this map.\n */\nexport const navIcons: Record<string, LucideIcon> = {\n courses: BookOpen,\n sessions: CalendarDays,\n enrollments: ClipboardList,\n settings: Settings2,\n user: Users,\n localUsers: Users,\n member: Users,\n organization: Building2,\n invitation: Mail,\n roles: ShieldCheck,\n}\n\n/** The icon a kayan is listed under. `Object.hasOwn` so a kayan named for a key of `Object.prototype` reads nothing off it. */\nexport const iconOf = (name: string): LucideIcon => (Object.hasOwn(navIcons, name) ? navIcons[name]! : Table2)\n\n/** One entry of the kayan index: what it is called, where it goes and what it is drawn with. */\nexport type NavEntry = { kayan: NavKayan; label: string; href: string; Icon: LucideIcon }\n\n/**\n * What the header's `<h1>` says: the kayan's plural on its list, its singular on a row of it, the\n * shell's own heading anywhere else. A row is matched by the rule `NavMain` marks an entry active by.\n */\nexport const titleFor = ({\n entries,\n copy,\n pathname,\n}: {\n entries: readonly NavEntry[]\n copy: { t: Translate; locale: string }\n pathname?: string\n}): string | undefined => {\n const row = pathname === undefined ? undefined : entries.find((one) => pathname.startsWith(`${one.href}/`))\n if (row) return titleOf({ kayan: row.kayan, copy })\n const listed = entries.find((one) => one.href === pathname)\n return listed ? listed.label : copy.t('admin.nav', 'Admin')\n}\n\n/** The kayan index: the kayans this request's access report lets the viewer read. */\nexport const navEntries = ({\n client,\n access,\n copy,\n href,\n}: {\n client: readonly NavKayan[]\n access: AccessReport\n copy: { t: Translate; locale: string }\n href: (kayan: NavKayan) => string\n}): NavEntry[] =>\n client\n // `=== true` so a kayan the report has no entry for is denied, including one named for a key\n // of `Object.prototype`.\n .filter((kayan) => access.collections?.[kayan.name]?.read === true)\n .map((kayan) => ({\n kayan,\n label: titleOf({ kayan, copy, many: !kayan.single }),\n href: href(kayan),\n Icon: iconOf(kayan.name),\n }))\n\n/** The values the theme toggle offers. */\nexport type Theme = 'dark' | 'light' | 'system'\n\n/** The themes the user menu offers unless the project passes its own to `NavUserMenu`. */\nexport const defaultThemes: readonly Theme[] = ['light', 'dark', 'system']\n\ntype ThemeProviderProps = {\n children: ReactNode\n defaultTheme?: Theme\n storageKey?: string\n}\n\ntype ThemeProviderState = {\n theme: Theme\n setTheme: (theme: Theme) => void\n}\n\nconst initialState: ThemeProviderState = {\n theme: 'system',\n setTheme: () => null,\n}\n\n/** `null` outside a provider, so a menu that offers the choice draws nothing where nothing would keep it. */\nconst ThemeProviderContext = createContext<ThemeProviderState | null>(null)\n\n/**\n * Keeps the theme in `localStorage` and puts `light` or `dark` on the document element. The stored\n * theme is read in the first browser render and not in an effect: a change after mount re-renders\n * consumers, and inside an unhydrated Suspense boundary that makes React drop the server's HTML.\n */\nexport function ThemeProvider({\n children,\n defaultTheme = 'system',\n storageKey = 'vite-ui-theme',\n}: ThemeProviderProps) {\n const [theme, setTheme] = useState<Theme>(() => {\n if (typeof window === 'undefined') return defaultTheme\n const stored = localStorage.getItem(storageKey)\n // A stray value under this key would hydrate mismatched against the server's default.\n return stored === 'dark' || stored === 'light' || stored === 'system' ? stored : defaultTheme\n })\n\n useEffect(() => {\n const root = window.document.documentElement\n root.classList.remove('light', 'dark')\n\n if (theme === 'system') {\n const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'\n root.classList.add(systemTheme)\n return\n }\n\n root.classList.add(theme)\n }, [theme])\n\n const value = useMemo(\n () => ({\n theme,\n setTheme: (newTheme: Theme) => {\n localStorage.setItem(storageKey, newTheme)\n setTheme(newTheme)\n },\n }),\n [theme, storageKey],\n )\n\n return <ThemeProviderContext.Provider value={value}>{children}</ThemeProviderContext.Provider>\n}\n\n/** The theme and its setter, or `null` where no `ThemeProvider` is above. */\nexport const useTheme = () => useContext(ThemeProviderContext)\n\n/** A theme toggle button whose menu offers light, dark and system. */\nexport function ModeToggle({ t }: { t: Translate }) {\n const { setTheme } = useTheme() ?? initialState\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger className={buttonVariants({ variant: 'outline', size: 'icon' })}>\n <HugeiconsIcon\n icon={Sun03Icon}\n className=\"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0\"\n />\n <HugeiconsIcon\n icon={Moon02Icon}\n className=\"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100\"\n />\n <span className=\"sr-only\">{t('admin.theme', 'Toggle theme')}</span>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"max-sm:**:data-[slot$=item]:min-h-11\">\n <DropdownMenuItem onClick={() => setTheme('light')}>{t('admin.themeLight', 'Light')}</DropdownMenuItem>\n <DropdownMenuItem onClick={() => setTheme('dark')}>{t('admin.themeDark', 'Dark')}</DropdownMenuItem>\n <DropdownMenuItem onClick={() => setTheme('system')}>\n {t('admin.themeSystem', 'System')}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n\n/**\n * A language's own name for itself (`ar` reads `العربية`), whatever the viewer reads in. It is the\n * same in every locale, so it carries no copy key. `of` throws a `RangeError` on a malformed tag,\n * so the tag itself is the fallback.\n */\nexport const endonym = (locale: string): string => {\n try {\n return new Intl.DisplayNames([locale], { type: 'language' }).of(locale) ?? locale\n } catch {\n return locale\n }\n}\n\n/** A radio row: a 44 pixel target on a phone, and the tick on the inline end in right-to-left pages, where the primitive draws it at the right. */\nconst radioRow = 'max-sm:min-h-11 rtl:pr-3 rtl:pl-8 rtl:[&>[data-slot=dropdown-menu-radio-item-indicator]]:right-auto rtl:[&>[data-slot=dropdown-menu-radio-item-indicator]]:left-2'\n\n/** One row of the user menu's locale group: the locale, what it calls itself, and whether it is the one being read in. */\nexport const localeEntries = (\n locales: readonly string[],\n current: string,\n): readonly { locale: string; label: string; active: boolean }[] =>\n locales.map((one) => ({ locale: one, label: endonym(one), active: one === current }))\n\n/** What the shell is told about the viewer, which is the two strings the user menu draws. */\nexport type NavUser = {\n name: string\n email: string\n /** The other screens the host links for this viewer, each a whole address; the user menu draws a link to each. */\n screens?: readonly { name: string; path: string }[]\n}\n\n/**\n * One organization the switcher lists. Two may share a name, so each carries a slug, which the\n * switcher shows beside a name that repeats.\n */\nexport type NavOrganization = { id: string; name: string; slug?: string }\n\nexport type ShellProps = {\n /** Every kayan and global the app chose to hand down, as little of each as the two keys of `NavKayan`. */\n client: readonly NavKayan[]\n access: AccessReport\n t: Translate\n locale: string\n /** Where an entry goes. A prop so the shell imports no router. */\n href: (kayan: NavKayan) => string\n /** The path the viewer is on, so the entry for it is marked current. A prop for the same reason `href` is one. */\n pathname?: string\n /** Which side the sidebar is on. */\n side?: 'left' | 'right'\n user: NavUser\n organizations: readonly NavOrganization[]\n activeOrganizationId?: string | null\n /** What switching the active organization means, which is the app's business because it is the auth arm's. */\n onSwitchOrganization: (id: string) => void\n onSignOut: () => void\n /** Where the host's permissions screen is. No row is drawn without it. */\n permissionsHref?: string\n /** Which locales the host reads in, so the user menu can offer them. A prop because mithaq resolves no locale. */\n locales?: readonly string[]\n /** What choosing a locale means. Without it the menu has no locale group. */\n onLocale?: (locale: string) => void\n children?: ReactNode\n}\n\nexport function Shell({\n access,\n activeOrganizationId,\n children,\n client,\n href,\n locale,\n locales,\n onLocale,\n onSignOut,\n onSwitchOrganization,\n organizations,\n pathname,\n permissionsHref,\n side = 'left',\n t,\n user,\n}: ShellProps) {\n const entries = navEntries({ client, access, copy: { t, locale }, href })\n return (\n <SidebarProvider\n style={\n {\n '--sidebar-width': 'calc(var(--spacing) * 72)',\n '--header-height': 'calc(var(--spacing) * 12)',\n } as CSSProperties\n }\n >\n <AppSidebar\n activeOrganizationId={activeOrganizationId}\n entries={entries}\n locale={locale}\n locales={locales}\n onLocale={onLocale}\n onSignOut={onSignOut}\n onSwitchOrganization={onSwitchOrganization}\n organizations={organizations}\n pathname={pathname}\n permissionsHref={permissionsHref}\n side={side}\n t={t}\n user={user}\n variant=\"inset\"\n />\n {/* `min-w-0`: the inset is a flex item beside the sidebar, and a table wider than a phone\n would widen the whole document. */}\n <SidebarInset className=\"min-w-0\">\n <SiteHeader t={t} title={titleFor({ entries, copy: { t, locale }, pathname })} />\n <div className=\"flex flex-1 flex-col px-4 pb-4 lg:px-6 lg:pb-6\">\n {/* `min-w-0`: a flex child whose minimum is its content pushes the sidebar off the page\n instead of scrolling a wide table. */}\n <div className=\"@container/main flex min-w-0 flex-1 flex-col gap-2\">{children}</div>\n </div>\n </SidebarInset>\n </SidebarProvider>\n )\n}\n\n/**\n * The sidebar: organization switcher, kayan index and user menu. It takes `side` and no `dir`,\n * since the direction is already on the document.\n */\nexport function AppSidebar({\n activeOrganizationId,\n entries,\n locale,\n locales,\n onLocale,\n onSignOut,\n onSwitchOrganization,\n organizations,\n pathname,\n permissionsHref,\n t,\n user,\n ...props\n}: {\n activeOrganizationId?: string | null\n entries: readonly NavEntry[]\n locale?: string\n locales?: readonly string[]\n onLocale?: (locale: string) => void\n onSignOut: () => void\n onSwitchOrganization: (id: string) => void\n organizations: readonly NavOrganization[]\n pathname?: string\n permissionsHref?: string\n t: Translate\n user: NavUser\n} & ComponentProps<typeof Sidebar>) {\n return (\n <NavSidebar {...props} t={t}>\n {/* One navigation landmark around the whole sidebar. A box of its own: the sidebar's props\n reach no element on a phone, where it is a sheet. */}\n <nav aria-label={t('admin.navigation', 'Navigation')} className=\"flex min-h-0 flex-1 flex-col\">\n <SidebarHeader>\n <OrganizationSwitcher\n activeOrganizationId={activeOrganizationId}\n onSwitch={onSwitchOrganization}\n organizations={organizations}\n t={t}\n />\n </SidebarHeader>\n <SidebarContent>\n <NavMain entries={entries} pathname={pathname} t={t} />\n <NavDocuments t={t} />\n <NavSecondary className=\"mt-auto\" permissionsHref={permissionsHref} t={t} />\n </SidebarContent>\n <SidebarFooter>\n <NavUserMenu locale={locale} locales={locales} onLocale={onLocale} onSignOut={onSignOut} t={t} user={user} />\n </SidebarFooter>\n </nav>\n </NavSidebar>\n )\n}\n\n/**\n * The kayan index as one sidebar group. An entry is current when the path is its own or a segment\n * under it, so no two entries are current at once. `render` and not `asChild`: base-ui composes a\n * button into an anchor through `useRender`.\n */\nexport function NavMain({ entries, pathname, t }: { entries: readonly NavEntry[]; pathname?: string; t: Translate }) {\n return (\n <SidebarGroup>\n <SidebarGroupContent className=\"flex flex-col gap-2\">\n {/* Quick Create and Inbox are placeholders: this admin has no destination for either. */}\n <SidebarMenu>\n <SidebarMenuItem className=\"flex items-center gap-2\">\n <SidebarMenuButton\n tooltip={t('admin.quickCreate', 'Quick Create')}\n className=\"bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear\"\n >\n <PlusCircle />\n <span>{t('admin.quickCreate', 'Quick Create')}</span>\n </SidebarMenuButton>\n <Button size=\"icon\" className=\"size-8 group-data-[collapsible=icon]:opacity-0\" variant=\"outline\">\n <Mail />\n <span className=\"sr-only\">{t('admin.inbox', 'Inbox')}</span>\n </Button>\n </SidebarMenuItem>\n </SidebarMenu>\n <SidebarMenu>\n {entries.map((entry) => {\n const isActive = pathname !== undefined && (pathname === entry.href || pathname.startsWith(`${entry.href}/`))\n return (\n <SidebarMenuItem key={entry.kayan.name}>\n <SidebarMenuButton\n isActive={isActive}\n render={\n <a href={entry.href}>\n <entry.Icon />\n <span>{entry.label}</span>\n </a>\n }\n />\n </SidebarMenuItem>\n )\n })}\n </SidebarMenu>\n </SidebarGroupContent>\n </SidebarGroup>\n )\n}\n\n/**\n * The documents group. `items` defaults to none, so only the label and the \"More\" row draw; the\n * row markup stays for an app that passes its own documents.\n */\nexport function NavDocuments({\n items = [],\n t,\n}: {\n items?: readonly { name: string; url: string; icon: LucideIcon }[]\n t: Translate\n}) {\n const { isMobile } = useSidebar()\n return (\n <SidebarGroup className=\"group-data-[collapsible=icon]:hidden\">\n <SidebarGroupLabel>{t('admin.documents', 'Documents')}</SidebarGroupLabel>\n <SidebarMenu>\n {items.map((item) => (\n <SidebarMenuItem key={item.name}>\n <SidebarMenuButton render={<a href={item.url} />}>\n <item.icon />\n <span>{item.name}</span>\n </SidebarMenuButton>\n <DropdownMenu>\n <DropdownMenuTrigger\n render={(props) => (\n <SidebarMenuAction showOnHover className=\"data-[state=open]:bg-accent rounded-sm\" {...props}>\n <MoreHorizontal />\n <span className=\"sr-only\">{t('admin.more', 'More')}</span>\n </SidebarMenuAction>\n )}\n />\n <DropdownMenuContent\n className=\"w-24 rounded-lg\"\n side={isMobile ? 'bottom' : 'right'}\n align={isMobile ? 'end' : 'start'}\n >\n <DropdownMenuItem>\n <Folder className=\"me-2 h-4 w-4\" />\n <span>{t('admin.open', 'Open')}</span>\n </DropdownMenuItem>\n <DropdownMenuItem>\n <Share2 className=\"me-2 h-4 w-4\" />\n <span>{t('admin.share', 'Share')}</span>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant=\"destructive\">\n <Trash2 className=\"me-2 h-4 w-4\" />\n <span>{t('admin.trash', 'Move to Trash')}</span>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </SidebarMenuItem>\n ))}\n <SidebarMenuItem>\n <SidebarMenuButton className=\"text-sidebar-foreground/70\">\n <MoreHorizontal className=\"text-sidebar-foreground/70\" />\n <span>{t('admin.more', 'More')}</span>\n </SidebarMenuButton>\n </SidebarMenuItem>\n </SidebarMenu>\n </SidebarGroup>\n )\n}\n\n/**\n * The rows at the foot of the sidebar: a permissions row where the host gives `permissionsHref`,\n * then Get Help and Search, which point at `#`. Their copy keys are quoted literals at the call\n * so the copy sweep can see them.\n */\nexport function NavSecondary({\n className,\n permissionsHref,\n t,\n}: {\n className?: string\n permissionsHref?: string\n t: Translate\n}) {\n return (\n <SidebarGroup className={className}>\n <SidebarGroupContent>\n <SidebarMenu>\n {permissionsHref ? (\n <SidebarMenuItem>\n <SidebarMenuButton render={<a href={permissionsHref} />}>\n <ShieldCheck />\n <span>{t('admin.permissions', 'Permissions')}</span>\n </SidebarMenuButton>\n </SidebarMenuItem>\n ) : null}\n <SidebarMenuItem>\n <SidebarMenuButton render={<a href=\"#\" />}>\n <HelpCircle />\n <span>{t('admin.getHelp', 'Get Help')}</span>\n </SidebarMenuButton>\n </SidebarMenuItem>\n <SidebarMenuItem>\n <SidebarMenuButton render={<a href=\"#\" />}>\n <Search />\n <span>{t('admin.search', 'Search')}</span>\n </SidebarMenuButton>\n </SidebarMenuItem>\n </SidebarMenu>\n </SidebarGroupContent>\n </SidebarGroup>\n )\n}\n\nconst initialsOf = (name: string) =>\n name\n .split(/\\s+/)\n .filter(Boolean)\n .slice(0, 2)\n .map((word) => word[0]!.toUpperCase())\n .join('') || '?'\n\n/**\n * The organization switcher: a dropdown listing every organization as a radio item, the active\n * one checked.\n */\nexport function OrganizationSwitcher({\n activeOrganizationId,\n onSwitch,\n organizations,\n t,\n}: {\n activeOrganizationId?: string | null\n onSwitch: (id: string) => void\n organizations: readonly NavOrganization[]\n t: Translate\n}) {\n const { isMobile } = useSidebar()\n const active = organizations.find((one) => one.id === activeOrganizationId) ?? organizations[0]\n /** An organization's name, with its slug beside it where another listed shares the name. */\n const named = (one: NavOrganization) =>\n one.slug && organizations.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 const badge = (\n <>\n <div className=\"flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground\">\n <GalleryVerticalEnd className=\"size-4\" />\n </div>\n <div className=\"grid flex-1 text-start text-sm leading-tight\">\n <span className=\"truncate font-semibold text-foreground\">\n {active ? named(active) : t('admin.noOrganization', 'No organization')}\n </span>\n <span className=\"truncate text-xs\">\n {active ? t('admin.organization', 'Organization') : ''}\n </span>\n </div>\n </>\n )\n // One organization or none is nothing to switch between, so it is drawn as a plain name.\n if (organizations.length < 2) {\n return (\n <SidebarMenu>\n <SidebarMenuItem data-organization=\"\">\n <div className=\"flex h-12 items-center gap-2 p-2\">{badge}</div>\n </SidebarMenuItem>\n </SidebarMenu>\n )\n }\n return (\n <SidebarMenu>\n <SidebarMenuItem>\n <DropdownMenu>\n <DropdownMenuTrigger\n render={(props, { open }) => (\n <SidebarMenuButton\n size=\"lg\"\n data-state={open ? 'open' : 'closed'}\n className=\"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground\"\n {...props}\n >\n {badge}\n <ChevronsUpDown className=\"ms-auto\" />\n </SidebarMenuButton>\n )}\n />\n <DropdownMenuContent\n className=\"min-w-56 rounded-lg max-sm:**:data-[slot$=item]:min-h-11\"\n align=\"start\"\n side={isMobile ? 'bottom' : 'right'}\n sideOffset={4}\n >\n <DropdownMenuGroup>\n <DropdownMenuLabel className=\"text-xs text-muted-foreground\">\n {t('admin.organizations', 'Organizations')}\n </DropdownMenuLabel>\n {/* Radio items, so the current organization is `aria-checked`; `closeOnClick`\n because a radio item does not close the menu by itself. */}\n <DropdownMenuRadioGroup value={active?.id} onValueChange={onSwitch}>\n {organizations.map((one) => (\n <DropdownMenuRadioItem key={one.id} value={one.id} closeOnClick className={radioRow}>\n {named(one)}\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n </SidebarMenuItem>\n </SidebarMenu>\n )\n}\n\n/**\n * The user menu: avatar, name and email, a link to each of the viewer's other screens, a locale\n * group, a theme group and sign-out. The locale and theme groups draw only where they can work.\n */\nexport function NavUserMenu({\n locale,\n locales,\n onLocale,\n onSignOut,\n t,\n themes = defaultThemes,\n user,\n}: {\n locale?: string\n locales?: readonly string[]\n onLocale?: (locale: string) => void\n onSignOut: () => void\n t: Translate\n /**\n * Which themes the menu offers: `defaultThemes` unless given, none for `[]`. Drawn only where a\n * `ThemeProvider` is above. Rows keep the order light, dark, system.\n */\n themes?: readonly Theme[]\n user: NavUser\n}) {\n const { isMobile } = useSidebar()\n const provided = useTheme()\n const screens = user.screens\n return (\n <SidebarMenu>\n <SidebarMenuItem>\n <DropdownMenu>\n <DropdownMenuTrigger\n render={(props, { open }) => (\n <SidebarMenuButton\n data-state={open ? 'open' : 'closed'}\n size=\"lg\"\n className=\"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground\"\n {...props}\n >\n <Avatar className=\"h-8 w-8 rounded-lg grayscale\">\n <AvatarFallback className=\"rounded-lg\">{initialsOf(user.name)}</AvatarFallback>\n </Avatar>\n <div className=\"grid flex-1 text-start text-sm leading-tight\">\n <span className=\"truncate font-medium\">{user.name}</span>\n <span className=\"text-muted-foreground truncate text-xs\">{user.email}</span>\n </div>\n <ChevronsUpDown className=\"ms-auto size-4\" />\n </SidebarMenuButton>\n )}\n />\n <DropdownMenuContent\n className=\"min-w-56 rounded-lg w-56 max-sm:**:data-[slot$=item]:min-h-11\"\n side={isMobile ? 'bottom' : 'top'}\n align=\"end\"\n sideOffset={4}\n >\n <DropdownMenuGroup>\n <DropdownMenuLabel className=\"p-0 font-normal\">\n <div className=\"flex items-center gap-2 px-1 py-1.5 text-start text-sm\">\n <Avatar className=\"h-8 w-8 rounded-lg\">\n <AvatarFallback className=\"rounded-lg\">{initialsOf(user.name)}</AvatarFallback>\n </Avatar>\n <div className=\"grid flex-1 text-start text-sm leading-tight\">\n <span className=\"truncate font-medium\">{user.name}</span>\n <span className=\"text-muted-foreground truncate text-xs\">{user.email}</span>\n </div>\n </div>\n </DropdownMenuLabel>\n </DropdownMenuGroup>\n {screens && screens.length > 0 ? (\n <>\n <DropdownMenuSeparator />\n <DropdownMenuGroup>\n {screens.map((one) => (\n <DropdownMenuItem key={one.name} className=\"max-sm:min-h-11\" render={<a href={one.path} />}>\n <LayoutGrid className=\"me-2 h-4 w-4\" />\n {t(`screens.${one.name}`, one.name) ?? one.name}\n </DropdownMenuItem>\n ))}\n </DropdownMenuGroup>\n </>\n ) : null}\n {locales && locales.length > 0 && onLocale ? (\n <>\n <DropdownMenuSeparator />\n <DropdownMenuGroup>\n {localeEntries(locales, locale ?? '').map((entry) => (\n <DropdownMenuItem key={entry.locale} className=\"max-sm:min-h-11\" onClick={() => onLocale(entry.locale)}>\n <Languages className=\"me-2 h-4 w-4\" />\n {entry.label}\n {entry.active ? <Check className=\"ms-auto h-4 w-4\" /> : null}\n </DropdownMenuItem>\n ))}\n </DropdownMenuGroup>\n </>\n ) : null}\n {provided && themes.length > 0 ? (\n <>\n <DropdownMenuSeparator />\n <DropdownMenuGroup>\n <DropdownMenuLabel className=\"text-xs text-muted-foreground\">{t('admin.theme', 'Toggle theme')}</DropdownMenuLabel>\n <DropdownMenuRadioGroup value={provided.theme} onValueChange={(next) => provided.setTheme(next as Theme)}>\n {(\n [\n ['light', Sun, t('admin.themeLight', 'Light')],\n ['dark', Moon, t('admin.themeDark', 'Dark')],\n ['system', Monitor, t('admin.themeSystem', 'System')],\n ] as const\n )\n .filter(([value]) => themes.includes(value))\n .map(([value, Icon, label]) => (\n <DropdownMenuRadioItem key={value} value={value} closeOnClick className={radioRow}>\n <Icon />\n {label}\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuGroup>\n </>\n ) : null}\n <DropdownMenuSeparator />\n <DropdownMenuItem className=\"max-sm:min-h-11\" onClick={onSignOut}>\n <LogOut className=\"me-2 h-4 w-4\" />\n {t('admin.signOut', 'Sign out')}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </SidebarMenuItem>\n </SidebarMenu>\n )\n}\n\n/**\n * `Sidebar`, except that on a phone its sheet is titled and described in the page's language;\n * the primitive's sheet is English only, which a screen reader announces on an Arabic page.\n * It has no close button: the overlay and Escape close it.\n */\nexport function NavSidebar({ children, side = 'left', t, ...props }: ComponentProps<typeof Sidebar> & { t: Translate }) {\n const { isMobile, openMobile, setOpenMobile } = useSidebar()\n if (!isMobile) return <Sidebar {...props} side={side} collapsible=\"offcanvas\">{children}</Sidebar>\n return (\n <Sheet open={openMobile} onOpenChange={setOpenMobile}>\n <SheetContent\n side={side}\n showCloseButton={false}\n data-sidebar=\"sidebar\"\n data-slot=\"sidebar\"\n data-mobile=\"true\"\n className=\"bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0\"\n style={{ '--sidebar-width': '18rem' } as CSSProperties}\n >\n <SheetHeader className=\"sr-only\">\n <SheetTitle>{t('admin.navigation', 'Navigation')}</SheetTitle>\n <SheetDescription>{t('admin.navigationAbout', 'The pages you can open.')}</SheetDescription>\n </SheetHeader>\n <div className=\"flex h-full w-full flex-col\">{children}</div>\n </SheetContent>\n </Sheet>\n )\n}\n\n/**\n * The header: sidebar trigger, the screen's title and the theme toggle. The trigger's\n * `aria-label` is its only name, so an Arabic page carries no English word for it.\n */\nexport function SiteHeader({ t, title }: { t: Translate; title?: string }) {\n return (\n <header className=\"flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)\">\n <div className=\"flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6\">\n <SidebarTrigger aria-label={t('admin.toggleSidebar', 'Toggle Sidebar')} className=\"-ms-1\" />\n <Separator orientation=\"vertical\" className=\"mx-2 data-[orientation=vertical]:h-4\" />\n <h1 className=\"text-base font-medium\">{title ?? t('admin.nav', 'Admin')}</h1>\n <div className=\"ms-auto flex items-center gap-2\">\n <ModeToggle t={t} />\n </div>\n </div>\n </header>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 b1ad44964867b0d65635652733a4821b190a720b1e283fc2f4aab3f82993effa\n",
19
+ "type": "registry:component",
20
+ "target": "@components/mithaq/nav.tsx"
21
+ }
22
+ ],
23
+ "type": "registry:component"
24
+ }