@byline/cli 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 (251) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +23 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +72 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/commands/doctor.d.ts +2 -0
  8. package/dist/commands/doctor.d.ts.map +1 -0
  9. package/dist/commands/doctor.js +36 -0
  10. package/dist/commands/doctor.js.map +1 -0
  11. package/dist/commands/init.d.ts +16 -0
  12. package/dist/commands/init.d.ts.map +1 -0
  13. package/dist/commands/init.js +76 -0
  14. package/dist/commands/init.js.map +1 -0
  15. package/dist/context.d.ts +38 -0
  16. package/dist/context.d.ts.map +1 -0
  17. package/dist/context.js +37 -0
  18. package/dist/context.js.map +1 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +4 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/lib/pg-url.d.ts +11 -0
  24. package/dist/lib/pg-url.d.ts.map +1 -0
  25. package/dist/lib/pg-url.js +22 -0
  26. package/dist/lib/pg-url.js.map +1 -0
  27. package/dist/manifest/deps.d.ts +29 -0
  28. package/dist/manifest/deps.d.ts.map +1 -0
  29. package/dist/manifest/deps.js +97 -0
  30. package/dist/manifest/deps.js.map +1 -0
  31. package/dist/manifest/env.d.ts +18 -0
  32. package/dist/manifest/env.d.ts.map +1 -0
  33. package/dist/manifest/env.js +38 -0
  34. package/dist/manifest/env.js.map +1 -0
  35. package/dist/phases/db-init.d.ts +3 -0
  36. package/dist/phases/db-init.d.ts.map +1 -0
  37. package/dist/phases/db-init.js +163 -0
  38. package/dist/phases/db-init.js.map +1 -0
  39. package/dist/phases/db.d.ts +11 -0
  40. package/dist/phases/db.d.ts.map +1 -0
  41. package/dist/phases/db.js +93 -0
  42. package/dist/phases/db.js.map +1 -0
  43. package/dist/phases/deps.d.ts +3 -0
  44. package/dist/phases/deps.d.ts.map +1 -0
  45. package/dist/phases/deps.js +115 -0
  46. package/dist/phases/deps.js.map +1 -0
  47. package/dist/phases/env.d.ts +3 -0
  48. package/dist/phases/env.d.ts.map +1 -0
  49. package/dist/phases/env.js +172 -0
  50. package/dist/phases/env.js.map +1 -0
  51. package/dist/phases/host.d.ts +3 -0
  52. package/dist/phases/host.d.ts.map +1 -0
  53. package/dist/phases/host.js +99 -0
  54. package/dist/phases/host.js.map +1 -0
  55. package/dist/phases/index.d.ts +7 -0
  56. package/dist/phases/index.d.ts.map +1 -0
  57. package/dist/phases/index.js +40 -0
  58. package/dist/phases/index.js.map +1 -0
  59. package/dist/phases/preflight.d.ts +4 -0
  60. package/dist/phases/preflight.d.ts.map +1 -0
  61. package/dist/phases/preflight.js +81 -0
  62. package/dist/phases/preflight.js.map +1 -0
  63. package/dist/phases/routes.d.ts +3 -0
  64. package/dist/phases/routes.d.ts.map +1 -0
  65. package/dist/phases/routes.js +145 -0
  66. package/dist/phases/routes.js.map +1 -0
  67. package/dist/phases/scaffold.d.ts +3 -0
  68. package/dist/phases/scaffold.d.ts.map +1 -0
  69. package/dist/phases/scaffold.js +113 -0
  70. package/dist/phases/scaffold.js.map +1 -0
  71. package/dist/phases/stub.d.ts +3 -0
  72. package/dist/phases/stub.d.ts.map +1 -0
  73. package/dist/phases/stub.js +25 -0
  74. package/dist/phases/stub.js.map +1 -0
  75. package/dist/phases/ui.d.ts +3 -0
  76. package/dist/phases/ui.d.ts.map +1 -0
  77. package/dist/phases/ui.js +93 -0
  78. package/dist/phases/ui.js.map +1 -0
  79. package/dist/phases/wire/index.d.ts +3 -0
  80. package/dist/phases/wire/index.d.ts.map +1 -0
  81. package/dist/phases/wire/index.js +67 -0
  82. package/dist/phases/wire/index.js.map +1 -0
  83. package/dist/phases/wire/root-tsx.d.ts +3 -0
  84. package/dist/phases/wire/root-tsx.d.ts.map +1 -0
  85. package/dist/phases/wire/root-tsx.js +57 -0
  86. package/dist/phases/wire/root-tsx.js.map +1 -0
  87. package/dist/phases/wire/server-ts.d.ts +3 -0
  88. package/dist/phases/wire/server-ts.d.ts.map +1 -0
  89. package/dist/phases/wire/server-ts.js +54 -0
  90. package/dist/phases/wire/server-ts.js.map +1 -0
  91. package/dist/phases/wire/shared.d.ts +34 -0
  92. package/dist/phases/wire/shared.d.ts.map +1 -0
  93. package/dist/phases/wire/shared.js +2 -0
  94. package/dist/phases/wire/shared.js.map +1 -0
  95. package/dist/phases/wire/start-ts.d.ts +3 -0
  96. package/dist/phases/wire/start-ts.d.ts.map +1 -0
  97. package/dist/phases/wire/start-ts.js +149 -0
  98. package/dist/phases/wire/start-ts.js.map +1 -0
  99. package/dist/phases/wire/tsconfig.d.ts +3 -0
  100. package/dist/phases/wire/tsconfig.d.ts.map +1 -0
  101. package/dist/phases/wire/tsconfig.js +105 -0
  102. package/dist/phases/wire/tsconfig.js.map +1 -0
  103. package/dist/phases/wire/vite-config.d.ts +3 -0
  104. package/dist/phases/wire/vite-config.d.ts.map +1 -0
  105. package/dist/phases/wire/vite-config.js +46 -0
  106. package/dist/phases/wire/vite-config.js.map +1 -0
  107. package/dist/prompts.d.ts +34 -0
  108. package/dist/prompts.d.ts.map +1 -0
  109. package/dist/prompts.js +49 -0
  110. package/dist/prompts.js.map +1 -0
  111. package/dist/runner.d.ts +5 -0
  112. package/dist/runner.d.ts.map +1 -0
  113. package/dist/runner.js +91 -0
  114. package/dist/runner.js.map +1 -0
  115. package/dist/state.d.ts +18 -0
  116. package/dist/state.d.ts.map +1 -0
  117. package/dist/state.js +68 -0
  118. package/dist/state.js.map +1 -0
  119. package/dist/templates/byline/admin.config.ts +41 -0
  120. package/dist/templates/byline/i18n.ts +47 -0
  121. package/dist/templates/byline/routes.ts +28 -0
  122. package/dist/templates/byline/seed.ts +19 -0
  123. package/dist/templates/byline/seeds/admin.ts +62 -0
  124. package/dist/templates/byline/server.config.ts +92 -0
  125. package/dist/templates/byline-examples/admin.config.ts +74 -0
  126. package/dist/templates/byline-examples/blocks/photo-block.ts +59 -0
  127. package/dist/templates/byline-examples/blocks/richtext-block.ts +35 -0
  128. package/dist/templates/byline-examples/collections/doc-example-flat-locale-all.ts +373 -0
  129. package/dist/templates/byline-examples/collections/doc-example-flat-locale-en.ts +283 -0
  130. package/dist/templates/byline-examples/collections/doc-example-tree-locale-all.ts +278 -0
  131. package/dist/templates/byline-examples/collections/doc-example-tree-locale-en.ts +205 -0
  132. package/dist/templates/byline-examples/collections/docs/admin.tsx +204 -0
  133. package/dist/templates/byline-examples/collections/docs/components/.gitkeep +0 -0
  134. package/dist/templates/byline-examples/collections/docs/components/feature-formatter.tsx +10 -0
  135. package/dist/templates/byline-examples/collections/docs/hooks/.gitkeep +0 -0
  136. package/dist/templates/byline-examples/collections/docs/index.ts +10 -0
  137. package/dist/templates/byline-examples/collections/docs/schema.ts +209 -0
  138. package/dist/templates/byline-examples/collections/docs-categories/admin.tsx +78 -0
  139. package/dist/templates/byline-examples/collections/docs-categories/components/.gitkeep +0 -0
  140. package/dist/templates/byline-examples/collections/docs-categories/hooks/.gitkeep +0 -0
  141. package/dist/templates/byline-examples/collections/docs-categories/index.ts +10 -0
  142. package/dist/templates/byline-examples/collections/docs-categories/schema.ts +33 -0
  143. package/dist/templates/byline-examples/collections/media/admin.tsx +188 -0
  144. package/dist/templates/byline-examples/collections/media/components/media-list-view.tsx +330 -0
  145. package/dist/templates/byline-examples/collections/media/components/media-thumbnail.tsx +63 -0
  146. package/dist/templates/byline-examples/collections/media/hooks/.gitkeep +0 -0
  147. package/dist/templates/byline-examples/collections/media/index.ts +10 -0
  148. package/dist/templates/byline-examples/collections/media/schema.ts +157 -0
  149. package/dist/templates/byline-examples/collections/news/admin.tsx +192 -0
  150. package/dist/templates/byline-examples/collections/news/components/.gitkeep +0 -0
  151. package/dist/templates/byline-examples/collections/news/hooks/.gitkeep +0 -0
  152. package/dist/templates/byline-examples/collections/news/index.ts +10 -0
  153. package/dist/templates/byline-examples/collections/news/schema.ts +91 -0
  154. package/dist/templates/byline-examples/collections/news-categories/admin.tsx +78 -0
  155. package/dist/templates/byline-examples/collections/news-categories/components/.gitkeep +0 -0
  156. package/dist/templates/byline-examples/collections/news-categories/hooks/.gitkeep +0 -0
  157. package/dist/templates/byline-examples/collections/news-categories/index.ts +10 -0
  158. package/dist/templates/byline-examples/collections/news-categories/schema.ts +33 -0
  159. package/dist/templates/byline-examples/collections/pages/admin.tsx +183 -0
  160. package/dist/templates/byline-examples/collections/pages/components/.gitkeep +0 -0
  161. package/dist/templates/byline-examples/collections/pages/hooks/.gitkeep +0 -0
  162. package/dist/templates/byline-examples/collections/pages/index.ts +10 -0
  163. package/dist/templates/byline-examples/collections/pages/schema.ts +96 -0
  164. package/dist/templates/byline-examples/components/length-indicator.tsx +138 -0
  165. package/dist/templates/byline-examples/components/pill.tsx +38 -0
  166. package/dist/templates/byline-examples/components/summary-length.tsx +39 -0
  167. package/dist/templates/byline-examples/fields/available-languages-field.ts +90 -0
  168. package/dist/templates/byline-examples/fields/lexical-richtext-compact.ts +88 -0
  169. package/dist/templates/byline-examples/i18n.ts +47 -0
  170. package/dist/templates/byline-examples/routes.ts +28 -0
  171. package/dist/templates/byline-examples/scripts/regenerate-media.ts +275 -0
  172. package/dist/templates/byline-examples/seed.ts +25 -0
  173. package/dist/templates/byline-examples/seeds/admin.ts +62 -0
  174. package/dist/templates/byline-examples/seeds/doc-categories.ts +71 -0
  175. package/dist/templates/byline-examples/seeds/docs.ts +293 -0
  176. package/dist/templates/byline-examples/seeds/news-categories.ts +71 -0
  177. package/dist/templates/byline-examples/server.config.ts +179 -0
  178. package/dist/templates/host/vite.config.ts +41 -0
  179. package/dist/templates/migrations/0000_condemned_kronos.sql +324 -0
  180. package/dist/templates/migrations/0001_sudden_phantom_reporter.sql +1 -0
  181. package/dist/templates/migrations/meta/0000_snapshot.json +2793 -0
  182. package/dist/templates/migrations/meta/0001_snapshot.json +2799 -0
  183. package/dist/templates/migrations/meta/_journal.json +20 -0
  184. package/dist/templates/routes/(byline)/admin/account/index.tsx +11 -0
  185. package/dist/templates/routes/(byline)/admin/collections/$collection/$id/api.tsx +16 -0
  186. package/dist/templates/routes/(byline)/admin/collections/$collection/$id/history.tsx +19 -0
  187. package/dist/templates/routes/(byline)/admin/collections/$collection/$id/index.tsx +16 -0
  188. package/dist/templates/routes/(byline)/admin/collections/$collection/create.tsx +11 -0
  189. package/dist/templates/routes/(byline)/admin/collections/$collection/index.tsx +11 -0
  190. package/dist/templates/routes/(byline)/admin/index.tsx +11 -0
  191. package/dist/templates/routes/(byline)/admin/permissions/index.tsx +11 -0
  192. package/dist/templates/routes/(byline)/admin/roles/$id/index.tsx +11 -0
  193. package/dist/templates/routes/(byline)/admin/roles/index.tsx +11 -0
  194. package/dist/templates/routes/(byline)/admin/route.tsx +11 -0
  195. package/dist/templates/routes/(byline)/admin/users/$id/index.tsx +11 -0
  196. package/dist/templates/routes/(byline)/admin/users/index.tsx +11 -0
  197. package/dist/templates/routes/(byline)/sign-in.tsx +11 -0
  198. package/dist/templates/ui-byline/blocks/photo-block/index.tsx +80 -0
  199. package/dist/templates/ui-byline/blocks/richtext-block/index.tsx +46 -0
  200. package/dist/templates/ui-byline/components/admonition/index.tsx +40 -0
  201. package/dist/templates/ui-byline/components/code/code-serializer.tsx +20 -0
  202. package/dist/templates/ui-byline/components/code/code.tsx +50 -0
  203. package/dist/templates/ui-byline/components/code/index.module.scss +137 -0
  204. package/dist/templates/ui-byline/components/code/index.ts +2 -0
  205. package/dist/templates/ui-byline/components/code/types.ts +5 -0
  206. package/dist/templates/ui-byline/components/code/utils.ts +20 -0
  207. package/dist/templates/ui-byline/components/heading-anchor/heading-anchor.tsx +69 -0
  208. package/dist/templates/ui-byline/components/heading-anchor/index.ts +1 -0
  209. package/dist/templates/ui-byline/components/heading-anchor/utils.ts +15 -0
  210. package/dist/templates/ui-byline/components/inline-image/index.tsx +109 -0
  211. package/dist/templates/ui-byline/components/layout/index.tsx +63 -0
  212. package/dist/templates/ui-byline/components/link/lang-link.tsx +70 -0
  213. package/dist/templates/ui-byline/components/link/link-field.tsx +298 -0
  214. package/dist/templates/ui-byline/components/link/link-lexical.tsx +191 -0
  215. package/dist/templates/ui-byline/components/list/index.ts +2 -0
  216. package/dist/templates/ui-byline/components/list/list-item.tsx +32 -0
  217. package/dist/templates/ui-byline/components/list/list.tsx +17 -0
  218. package/dist/templates/ui-byline/components/responsive-image/index.tsx +205 -0
  219. package/dist/templates/ui-byline/components/richtext-lexical/index.tsx +31 -0
  220. package/dist/templates/ui-byline/components/richtext-lexical/serialize/index.tsx +249 -0
  221. package/dist/templates/ui-byline/components/richtext-lexical/serialize/richtext-node-formats.ts +66 -0
  222. package/dist/templates/ui-byline/components/richtext-lexical/serialize/types.ts +48 -0
  223. package/dist/templates/ui-byline/components/richtext-lexical/serialize/utils.ts +15 -0
  224. package/dist/templates/ui-byline/components/table-cell/index.tsx +36 -0
  225. package/dist/templates/ui-byline/components/vimeo/index.tsx +21 -0
  226. package/dist/templates/ui-byline/components/youtube/index.tsx +22 -0
  227. package/dist/templates/ui-byline/render-blocks.tsx +71 -0
  228. package/dist/templates/ui-byline/types/i18n.ts +14 -0
  229. package/dist/templates/ui-byline/utils/image-sources.ts +102 -0
  230. package/dist/templates/ui-byline/utils/to-kebab-case.ts +5 -0
  231. package/dist/types.d.ts +54 -0
  232. package/dist/types.d.ts.map +1 -0
  233. package/dist/types.js +2 -0
  234. package/dist/types.js.map +1 -0
  235. package/dist/ui/diff.d.ts +4 -0
  236. package/dist/ui/diff.d.ts.map +1 -0
  237. package/dist/ui/diff.js +23 -0
  238. package/dist/ui/diff.js.map +1 -0
  239. package/dist/ui/grid.d.ts +7 -0
  240. package/dist/ui/grid.d.ts.map +1 -0
  241. package/dist/ui/grid.js +24 -0
  242. package/dist/ui/grid.js.map +1 -0
  243. package/dist/ui/logger.d.ts +14 -0
  244. package/dist/ui/logger.d.ts.map +1 -0
  245. package/dist/ui/logger.js +30 -0
  246. package/dist/ui/logger.js.map +1 -0
  247. package/dist/ui/snippet.d.ts +2 -0
  248. package/dist/ui/snippet.d.ts.map +1 -0
  249. package/dist/ui/snippet.js +7 -0
  250. package/dist/ui/snippet.js.map +1 -0
  251. package/package.json +69 -0
@@ -0,0 +1,330 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * MediaListView — card-grid list view for the Media collection.
11
+ *
12
+ * Registered via `CollectionAdminConfig.listView` in MediaAdmin, replacing
13
+ * the default table-based ListView. Receives the same paginated API data, so
14
+ * no additional API parameters or endpoints are required.
15
+ *
16
+ * Controls:
17
+ * - Search bar (delegates to `?query=` URL param)
18
+ * - Order-by dropdown (maps to `?order=` + `?desc=` URL params)
19
+ * - Top + bottom pagination (RouterPager)
20
+ */
21
+
22
+ import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
23
+
24
+ import type { ListViewComponentProps, StoredFileValue, WorkflowStatus } from '@byline/core'
25
+ import type { AnyCollectionSchemaTypes } from '@byline/core/zod-schemas'
26
+ import { RouterPager } from '@byline/host-tanstack-start/admin-shell/chrome/router-pager'
27
+ import { Container, IconButton, LoaderRing, PlusIcon, Search, Section, Select } from '@byline/ui'
28
+ import { LocalDateTime } from '@byline/ui/react/fields'
29
+
30
+ import { FormatBadge } from './media-thumbnail'
31
+
32
+ function formatNumber(n: number, decimalPlaces: number): string {
33
+ if (typeof n !== 'number' || Number.isNaN(n)) {
34
+ throw new TypeError('Input must be a valid number')
35
+ }
36
+ return n.toLocaleString('en-US', {
37
+ minimumFractionDigits: decimalPlaces,
38
+ maximumFractionDigits: decimalPlaces,
39
+ })
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Helpers
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Derive the avif thumbnail URL from the original storageUrl using the same
48
+ * convention as MediaThumbnailCell / the Sharp upload processor.
49
+ * `/uploads/media/2026/02/img.jpg` → `/uploads/media/2026/02/img-thumbnail.avif`
50
+ */
51
+ function deriveThumbnailUrl(storageUrl: string): string {
52
+ return storageUrl.replace(/\.[^.]+$/, '-thumbnail.avif')
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Order-by config
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Composite order values that encode both `order` field and `desc` direction.
61
+ * Format: `"<field>_<asc|desc>"` — split at the last underscore when applied.
62
+ */
63
+ const ORDER_OPTIONS = [
64
+ { value: 'updated_at_desc', label: 'Recently Updated' },
65
+ { value: 'updated_at_asc', label: 'Oldest Updated' },
66
+ { value: 'title_asc', label: 'Title A–Z' },
67
+ { value: 'title_desc', label: 'Title Z–A' },
68
+ { value: 'created_at_desc', label: 'Newest Created' },
69
+ { value: 'created_at_asc', label: 'Oldest Created' },
70
+ ] as const
71
+
72
+ type OrderValue = (typeof ORDER_OPTIONS)[number]['value']
73
+
74
+ function parseOrderValue(order?: string, desc?: boolean): OrderValue {
75
+ if (!order) return 'updated_at_desc'
76
+ const candidate = `${order}_${desc ? 'desc' : 'asc'}` as OrderValue
77
+ return ORDER_OPTIONS.some((o) => o.value === candidate) ? candidate : 'updated_at_desc'
78
+ }
79
+
80
+ function splitOrderValue(value: OrderValue): { order: string; desc: boolean } {
81
+ const i = value.lastIndexOf('_')
82
+ return { order: value.slice(0, i), desc: value.slice(i + 1) === 'desc' }
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Sub-components
87
+ // ---------------------------------------------------------------------------
88
+
89
+ function Stats({ total }: { total: number }) {
90
+ return (
91
+ <span className="flex items-center justify-center h-7 min-w-7 px-1.5 py-1.25 -mb-1 whitespace-nowrap text-sm leading-0 bg-gray-25 dark:bg-canvas-700 border rounded-md">
92
+ {formatNumber(total, 0)}
93
+ </span>
94
+ )
95
+ }
96
+
97
+ function StatusBadge({
98
+ status,
99
+ workflowStatuses,
100
+ }: {
101
+ status: string
102
+ workflowStatuses: WorkflowStatus[]
103
+ }) {
104
+ const label = workflowStatuses.find((s) => s.name === status)?.label ?? status
105
+ const colour =
106
+ status === 'published'
107
+ ? 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30'
108
+ : status === 'archived'
109
+ ? 'bg-gray-500/15 text-gray-400 ring-gray-500/30'
110
+ : 'bg-amber-500/15 text-amber-400 ring-amber-500/30'
111
+ return (
112
+ <span
113
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${colour}`}
114
+ >
115
+ {label}
116
+ </span>
117
+ )
118
+ }
119
+
120
+ function EmptyState() {
121
+ return (
122
+ <div className="flex flex-col items-center justify-center py-20 text-gray-500 dark:text-gray-400">
123
+ <LoaderRing className="mb-4 opacity-0" size={1} color="transparent" />
124
+ <p className="text-sm">No media items found.</p>
125
+ </div>
126
+ )
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // MediaListView
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export function MediaListView({
134
+ data,
135
+ workflowStatuses = [],
136
+ }: ListViewComponentProps<AnyCollectionSchemaTypes['ListType']>) {
137
+ const navigate = useNavigate()
138
+ const location = useRouterState({ select: (s) => s.location })
139
+ const collectionPath = data.included.collection.path
140
+ const search = location.search as Record<string, any>
141
+
142
+ // ---- search ----
143
+
144
+ const handleOnSearch = (query: string): void => {
145
+ if (query != null && query.length > 0) {
146
+ const params = structuredClone(search)
147
+ delete params.page
148
+ params.query = query
149
+ navigate({
150
+ to: '/admin/collections/$collection',
151
+ params: { collection: collectionPath },
152
+ search: params,
153
+ })
154
+ }
155
+ }
156
+
157
+ const handleOnClear = (): void => {
158
+ const params = structuredClone(search)
159
+ delete params.page
160
+ delete params.query
161
+ navigate({
162
+ to: '/admin/collections/$collection',
163
+ params: { collection: collectionPath },
164
+ search: params,
165
+ })
166
+ }
167
+
168
+ // ---- order-by ----
169
+
170
+ const currentOrder = parseOrderValue(search.order, search.desc)
171
+
172
+ const handleOrderChange = (value: string | null): void => {
173
+ if (value == null) return
174
+ const { order, desc } = splitOrderValue(value as OrderValue)
175
+ const params = structuredClone(search)
176
+ delete params.page
177
+ params.order = order
178
+ params.desc = desc
179
+ navigate({
180
+ to: '/admin/collections/$collection',
181
+ params: { collection: collectionPath },
182
+ search: params,
183
+ })
184
+ }
185
+
186
+ // ---- render ----
187
+
188
+ return (
189
+ <Section>
190
+ <Container>
191
+ {/* ---- Header ---- */}
192
+ <div className="flex items-center gap-3 py-0.5">
193
+ <h1 className="m-0! pb-0.5">{data.included.collection.labels.plural as string}</h1>
194
+ <Stats total={data.meta.total} />
195
+ <IconButton
196
+ aria-label="Upload New Media"
197
+ render={
198
+ <Link
199
+ className="ml-auto"
200
+ to="/admin/collections/$collection/create"
201
+ params={{ collection: collectionPath }}
202
+ />
203
+ }
204
+ >
205
+ <PlusIcon height="18px" width="18px" svgClassName="stroke-white" />
206
+ </IconButton>
207
+ </div>
208
+
209
+ {/* ---- Toolbar ---- */}
210
+ <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap items-start sm:items-center mt-3 mb-4">
211
+ <Search
212
+ onSearch={handleOnSearch}
213
+ onClear={handleOnClear}
214
+ inputSize="sm"
215
+ placeholder="Search media…"
216
+ className="w-full max-w-87.5"
217
+ />
218
+
219
+ {/* Order-by */}
220
+ <div className="flex items-center gap-2 sm:ml-auto">
221
+ <label
222
+ htmlFor="media_order"
223
+ className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap"
224
+ >
225
+ Order by
226
+ </label>
227
+ <Select
228
+ id="media_order"
229
+ name="media_order"
230
+ size="sm"
231
+ value={currentOrder}
232
+ onValueChange={handleOrderChange}
233
+ items={[...ORDER_OPTIONS]}
234
+ />
235
+ </div>
236
+
237
+ {/* Top pager */}
238
+ <RouterPager
239
+ page={data.meta.page}
240
+ count={data.meta.totalPages}
241
+ showFirstButton
242
+ showLastButton
243
+ componentName="pagerTop"
244
+ aria-label="Top Pager"
245
+ />
246
+ </div>
247
+
248
+ {/* ---- Card grid ---- */}
249
+ {data.docs.length === 0 ? (
250
+ <EmptyState />
251
+ ) : (
252
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 gap-4 mb-6 mt-4">
253
+ {(data.docs as any[]).map((doc) => {
254
+ const fields = doc.fields ?? {}
255
+ const img = fields.image as StoredFileValue | null | undefined
256
+ const thumbUrl = img?.storageUrl
257
+ ? img.thumbnailGenerated
258
+ ? deriveThumbnailUrl(img.storageUrl)
259
+ : img.storageUrl
260
+ : null
261
+
262
+ const updatedAt = doc.updatedAt ?? null
263
+
264
+ return (
265
+ <Link
266
+ key={doc.id}
267
+ to="/admin/collections/$collection/$id"
268
+ params={{ collection: collectionPath, id: doc.id }}
269
+ className="group flex flex-col overflow-hidden rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-canvas-800 hover:border-indigo-400 dark:hover:border-indigo-500 transition-colors no-underline"
270
+ >
271
+ {/* Thumbnail */}
272
+ <div className="relative aspect-square overflow-hidden bg-gray-100 dark:bg-canvas-700">
273
+ {thumbUrl ? (
274
+ <img
275
+ src={thumbUrl}
276
+ alt={fields.altText ?? img?.originalFilename ?? ''}
277
+ className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
278
+ loading="lazy"
279
+ />
280
+ ) : (
281
+ <span className="flex h-full w-full items-center justify-center text-xs text-gray-400 dark:text-gray-600">
282
+ No image
283
+ </span>
284
+ )}
285
+ </div>
286
+
287
+ {/* Card meta */}
288
+ <div className="flex flex-col gap-1.5 p-2 min-w-0">
289
+ <span
290
+ className="truncate text-sm font-medium leading-snug"
291
+ title={fields.title ?? ''}
292
+ >
293
+ {fields.title ?? '—'}
294
+ </span>
295
+ <div className="flex flex-wrap items-center justify-between gap-1">
296
+ <div className="flex flex-wrap items-center gap-1">
297
+ {doc.status && (
298
+ <StatusBadge status={doc.status} workflowStatuses={workflowStatuses} />
299
+ )}
300
+ {img?.imageFormat && <FormatBadge format={img.imageFormat} />}
301
+ </div>
302
+ {updatedAt && (
303
+ <span className="ml-auto text-xs text-gray-500 dark:text-gray-400">
304
+ <LocalDateTime value={updatedAt} mode="date" />
305
+ </span>
306
+ )}
307
+ </div>
308
+ </div>
309
+ </Link>
310
+ )
311
+ })}
312
+ </div>
313
+ )}
314
+
315
+ {/* ---- Bottom pagination ---- */}
316
+ <div className="flex justify-end mb-5">
317
+ <RouterPager
318
+ smoothScrollToTop={true}
319
+ page={data.meta.page}
320
+ count={data.meta.totalPages}
321
+ showFirstButton
322
+ showLastButton
323
+ componentName="pagerBottom"
324
+ aria-label="Bottom Pager"
325
+ />
326
+ </div>
327
+ </Container>
328
+ </Section>
329
+ )
330
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { FormatterProps, StoredFileValue } from '@byline/core'
10
+
11
+ /**
12
+ * Derive the thumbnail URL from the original storageUrl.
13
+ *
14
+ * Sharp writes variants as siblings of the original file using the naming
15
+ * convention `<basename>-<variantName>.<outputExt>`:
16
+ * `/uploads/media/2026/02/abc-photo.jpg`
17
+ * → `/uploads/media/2026/02/abc-photo-thumbnail.avif`
18
+ */
19
+ function deriveThumbnailUrl(storageUrl: string): string {
20
+ return storageUrl.replace(/\.[^.]+$/, '-thumbnail.avif')
21
+ }
22
+
23
+ /**
24
+ * FormatBadge renders a muted pill showing the image format (e.g. JPEG, PNG, SVG).
25
+ * Intended for use alongside the status badge in list-view card meta.
26
+ */
27
+ export function FormatBadge({ format }: { format: string }) {
28
+ return (
29
+ <span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset bg-gray-500/10 text-gray-400 ring-gray-500/20">
30
+ {format.toUpperCase()}
31
+ </span>
32
+ )
33
+ }
34
+
35
+ /**
36
+ * MediaThumbnailCell renders a small preview image in the Media list view.
37
+ * When the `thumbnail` variant has been generated, the smaller avif is used;
38
+ * otherwise the original storage URL is shown.
39
+ */
40
+ export function MediaThumbnail({ record }: FormatterProps) {
41
+ const doc = record as Record<string, any>
42
+ const fields = doc.fields ?? {}
43
+ const img = fields.image as StoredFileValue | null | undefined
44
+
45
+ if (!img?.storageUrl) {
46
+ return (
47
+ <span className="inline-flex items-center justify-center w-18 h-18 bg-gray-800 rounded text-gray-600 text-[0.6rem]">
48
+
49
+ </span>
50
+ )
51
+ }
52
+
53
+ const thumbUrl = img.thumbnailGenerated ? deriveThumbnailUrl(img.storageUrl) : img.storageUrl
54
+
55
+ return (
56
+ <img
57
+ src={thumbUrl}
58
+ alt={img.originalFilename ?? img.filename}
59
+ className="w-18 h-18 object-cover rounded border border-gray-700"
60
+ loading="lazy"
61
+ />
62
+ )
63
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ export { MediaAdmin } from './admin.js'
10
+ export { Media } from './schema.js'
@@ -0,0 +1,157 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { AfterStoreContext, BeforeStoreContext, CollectionFieldData } from '@byline/core'
10
+ import { defineCollection, defineWorkflow } from '@byline/core'
11
+
12
+ // ---- Schema (server-safe, no UI concerns) ----
13
+
14
+ /**
15
+ * Media — the reference upload collection.
16
+ *
17
+ * Upload-capability is declared on individual `image` / `file` fields via
18
+ * an `upload` block. The auto-mounted endpoint at
19
+ * `POST /admin/api/<collection-path>/upload`
20
+ * accepts a `field` selector to choose which upload-capable field
21
+ * receives the file (default when only one such field exists).
22
+ *
23
+ * Other collections reference items from this collection via a `relation`
24
+ * field pointing at `'media'` — the populated relation envelope carries
25
+ * the persisted `variants` array so a `<picture>` / `srcset` can be built
26
+ * without a second round-trip.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * // In another collection's fields:
31
+ * {
32
+ * name: 'featuredImage',
33
+ * label: 'Featured Image',
34
+ * type: 'relation',
35
+ * targetCollection: 'media',
36
+ * displayField: 'title',
37
+ * }
38
+ * ```
39
+ */
40
+ export const Media = defineCollection({
41
+ path: 'media',
42
+ labels: {
43
+ singular: 'Media Item',
44
+ plural: 'Media',
45
+ },
46
+ useAsTitle: 'title',
47
+ workflow: defineWorkflow({
48
+ draft: { label: 'Draft', verb: 'Revert to Draft' },
49
+ published: { label: 'Active', verb: 'Activate' },
50
+ archived: { label: 'Archived', verb: 'Archive' },
51
+ }),
52
+ showStats: true,
53
+ search: { fields: ['title', 'caption'] },
54
+ fields: [
55
+ {
56
+ name: 'image',
57
+ label: 'Image',
58
+ type: 'image',
59
+ upload: {
60
+ // Allow common image types. Extend with 'video/*', 'application/pdf'
61
+ // etc. for a more general media field.
62
+ mimeTypes: [
63
+ 'image/jpeg',
64
+ 'image/png',
65
+ 'image/gif',
66
+ 'image/webp',
67
+ 'image/avif',
68
+ 'image/svg+xml',
69
+ ],
70
+ // 20 MB limit per file.
71
+ maxFileSize: 20 * 1024 * 1024,
72
+ // Named Sharp variants generated after the original is stored.
73
+ // AVIF is widely supported across modern browsers (Chrome 85+,
74
+ // Firefox 93+, Safari 16.4+) and typically yields ~20–30 %
75
+ // smaller files than webp at comparable quality. Sharp's avif
76
+ // defaults to a lower numeric quality (~50) than webp; the
77
+ // values below are tuned for that — image-processor.ts uses
78
+ // `quality: size.quality ?? 55` for the avif branch.
79
+ sizes: [
80
+ {
81
+ name: 'thumbnail',
82
+ width: 400,
83
+ height: 400,
84
+ fit: 'cover',
85
+ format: 'avif',
86
+ quality: 55,
87
+ },
88
+ {
89
+ name: 'card',
90
+ width: 600,
91
+ fit: 'inside',
92
+ format: 'avif',
93
+ quality: 55,
94
+ },
95
+ {
96
+ name: 'mobile',
97
+ width: 768,
98
+ fit: 'inside',
99
+ format: 'avif',
100
+ quality: 55,
101
+ },
102
+ {
103
+ name: 'tablet',
104
+ width: 1280,
105
+ fit: 'inside',
106
+ format: 'avif',
107
+ quality: 55,
108
+ },
109
+ {
110
+ name: 'desktop',
111
+ width: 2100,
112
+ fit: 'inside',
113
+ format: 'avif',
114
+ quality: 55,
115
+ },
116
+ ],
117
+ hooks: {
118
+ beforeStore: (ctx: BeforeStoreContext) => {
119
+ console.log('beforeStore hook called', ctx)
120
+ },
121
+ afterStore: (ctx: AfterStoreContext) => {
122
+ console.log('afterStore hook called', ctx)
123
+ },
124
+ },
125
+ },
126
+ },
127
+ // Descriptive metadata fields.
128
+ {
129
+ name: 'title',
130
+ label: 'Title',
131
+ type: 'text',
132
+ helpText: 'A short, descriptive title for this media item.',
133
+ },
134
+ {
135
+ name: 'altText',
136
+ label: 'Alt Text',
137
+ type: 'text',
138
+ helpText: 'Descriptive text for screen readers and SEO. Recommended for images.',
139
+ },
140
+ {
141
+ name: 'caption',
142
+ label: 'Caption',
143
+ type: 'textArea',
144
+ optional: true,
145
+ helpText: 'Optional caption displayed beneath the image in the front-end.',
146
+ },
147
+ {
148
+ name: 'credit',
149
+ label: 'Credit / Attribution',
150
+ type: 'text',
151
+ optional: true,
152
+ helpText: 'Photographer, agency, or copyright holder.',
153
+ },
154
+ ],
155
+ })
156
+
157
+ export type MediaFields = CollectionFieldData<typeof Media>