@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.
- package/LICENSE +373 -0
- package/README.md +23 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +72 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +36 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.d.ts +16 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +76 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/context.d.ts +38 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +37 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/pg-url.d.ts +11 -0
- package/dist/lib/pg-url.d.ts.map +1 -0
- package/dist/lib/pg-url.js +22 -0
- package/dist/lib/pg-url.js.map +1 -0
- package/dist/manifest/deps.d.ts +29 -0
- package/dist/manifest/deps.d.ts.map +1 -0
- package/dist/manifest/deps.js +97 -0
- package/dist/manifest/deps.js.map +1 -0
- package/dist/manifest/env.d.ts +18 -0
- package/dist/manifest/env.d.ts.map +1 -0
- package/dist/manifest/env.js +38 -0
- package/dist/manifest/env.js.map +1 -0
- package/dist/phases/db-init.d.ts +3 -0
- package/dist/phases/db-init.d.ts.map +1 -0
- package/dist/phases/db-init.js +163 -0
- package/dist/phases/db-init.js.map +1 -0
- package/dist/phases/db.d.ts +11 -0
- package/dist/phases/db.d.ts.map +1 -0
- package/dist/phases/db.js +93 -0
- package/dist/phases/db.js.map +1 -0
- package/dist/phases/deps.d.ts +3 -0
- package/dist/phases/deps.d.ts.map +1 -0
- package/dist/phases/deps.js +115 -0
- package/dist/phases/deps.js.map +1 -0
- package/dist/phases/env.d.ts +3 -0
- package/dist/phases/env.d.ts.map +1 -0
- package/dist/phases/env.js +172 -0
- package/dist/phases/env.js.map +1 -0
- package/dist/phases/host.d.ts +3 -0
- package/dist/phases/host.d.ts.map +1 -0
- package/dist/phases/host.js +99 -0
- package/dist/phases/host.js.map +1 -0
- package/dist/phases/index.d.ts +7 -0
- package/dist/phases/index.d.ts.map +1 -0
- package/dist/phases/index.js +40 -0
- package/dist/phases/index.js.map +1 -0
- package/dist/phases/preflight.d.ts +4 -0
- package/dist/phases/preflight.d.ts.map +1 -0
- package/dist/phases/preflight.js +81 -0
- package/dist/phases/preflight.js.map +1 -0
- package/dist/phases/routes.d.ts +3 -0
- package/dist/phases/routes.d.ts.map +1 -0
- package/dist/phases/routes.js +145 -0
- package/dist/phases/routes.js.map +1 -0
- package/dist/phases/scaffold.d.ts +3 -0
- package/dist/phases/scaffold.d.ts.map +1 -0
- package/dist/phases/scaffold.js +113 -0
- package/dist/phases/scaffold.js.map +1 -0
- package/dist/phases/stub.d.ts +3 -0
- package/dist/phases/stub.d.ts.map +1 -0
- package/dist/phases/stub.js +25 -0
- package/dist/phases/stub.js.map +1 -0
- package/dist/phases/ui.d.ts +3 -0
- package/dist/phases/ui.d.ts.map +1 -0
- package/dist/phases/ui.js +93 -0
- package/dist/phases/ui.js.map +1 -0
- package/dist/phases/wire/index.d.ts +3 -0
- package/dist/phases/wire/index.d.ts.map +1 -0
- package/dist/phases/wire/index.js +67 -0
- package/dist/phases/wire/index.js.map +1 -0
- package/dist/phases/wire/root-tsx.d.ts +3 -0
- package/dist/phases/wire/root-tsx.d.ts.map +1 -0
- package/dist/phases/wire/root-tsx.js +57 -0
- package/dist/phases/wire/root-tsx.js.map +1 -0
- package/dist/phases/wire/server-ts.d.ts +3 -0
- package/dist/phases/wire/server-ts.d.ts.map +1 -0
- package/dist/phases/wire/server-ts.js +54 -0
- package/dist/phases/wire/server-ts.js.map +1 -0
- package/dist/phases/wire/shared.d.ts +34 -0
- package/dist/phases/wire/shared.d.ts.map +1 -0
- package/dist/phases/wire/shared.js +2 -0
- package/dist/phases/wire/shared.js.map +1 -0
- package/dist/phases/wire/start-ts.d.ts +3 -0
- package/dist/phases/wire/start-ts.d.ts.map +1 -0
- package/dist/phases/wire/start-ts.js +149 -0
- package/dist/phases/wire/start-ts.js.map +1 -0
- package/dist/phases/wire/tsconfig.d.ts +3 -0
- package/dist/phases/wire/tsconfig.d.ts.map +1 -0
- package/dist/phases/wire/tsconfig.js +105 -0
- package/dist/phases/wire/tsconfig.js.map +1 -0
- package/dist/phases/wire/vite-config.d.ts +3 -0
- package/dist/phases/wire/vite-config.d.ts.map +1 -0
- package/dist/phases/wire/vite-config.js +46 -0
- package/dist/phases/wire/vite-config.js.map +1 -0
- package/dist/prompts.d.ts +34 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +49 -0
- package/dist/prompts.js.map +1 -0
- package/dist/runner.d.ts +5 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +91 -0
- package/dist/runner.js.map +1 -0
- package/dist/state.d.ts +18 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +68 -0
- package/dist/state.js.map +1 -0
- package/dist/templates/byline/admin.config.ts +41 -0
- package/dist/templates/byline/i18n.ts +47 -0
- package/dist/templates/byline/routes.ts +28 -0
- package/dist/templates/byline/seed.ts +19 -0
- package/dist/templates/byline/seeds/admin.ts +62 -0
- package/dist/templates/byline/server.config.ts +92 -0
- package/dist/templates/byline-examples/admin.config.ts +74 -0
- package/dist/templates/byline-examples/blocks/photo-block.ts +59 -0
- package/dist/templates/byline-examples/blocks/richtext-block.ts +35 -0
- package/dist/templates/byline-examples/collections/doc-example-flat-locale-all.ts +373 -0
- package/dist/templates/byline-examples/collections/doc-example-flat-locale-en.ts +283 -0
- package/dist/templates/byline-examples/collections/doc-example-tree-locale-all.ts +278 -0
- package/dist/templates/byline-examples/collections/doc-example-tree-locale-en.ts +205 -0
- package/dist/templates/byline-examples/collections/docs/admin.tsx +204 -0
- package/dist/templates/byline-examples/collections/docs/components/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/docs/components/feature-formatter.tsx +10 -0
- package/dist/templates/byline-examples/collections/docs/hooks/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/docs/index.ts +10 -0
- package/dist/templates/byline-examples/collections/docs/schema.ts +209 -0
- package/dist/templates/byline-examples/collections/docs-categories/admin.tsx +78 -0
- package/dist/templates/byline-examples/collections/docs-categories/components/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/docs-categories/hooks/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/docs-categories/index.ts +10 -0
- package/dist/templates/byline-examples/collections/docs-categories/schema.ts +33 -0
- package/dist/templates/byline-examples/collections/media/admin.tsx +188 -0
- package/dist/templates/byline-examples/collections/media/components/media-list-view.tsx +330 -0
- package/dist/templates/byline-examples/collections/media/components/media-thumbnail.tsx +63 -0
- package/dist/templates/byline-examples/collections/media/hooks/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/media/index.ts +10 -0
- package/dist/templates/byline-examples/collections/media/schema.ts +157 -0
- package/dist/templates/byline-examples/collections/news/admin.tsx +192 -0
- package/dist/templates/byline-examples/collections/news/components/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/news/hooks/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/news/index.ts +10 -0
- package/dist/templates/byline-examples/collections/news/schema.ts +91 -0
- package/dist/templates/byline-examples/collections/news-categories/admin.tsx +78 -0
- package/dist/templates/byline-examples/collections/news-categories/components/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/news-categories/hooks/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/news-categories/index.ts +10 -0
- package/dist/templates/byline-examples/collections/news-categories/schema.ts +33 -0
- package/dist/templates/byline-examples/collections/pages/admin.tsx +183 -0
- package/dist/templates/byline-examples/collections/pages/components/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/pages/hooks/.gitkeep +0 -0
- package/dist/templates/byline-examples/collections/pages/index.ts +10 -0
- package/dist/templates/byline-examples/collections/pages/schema.ts +96 -0
- package/dist/templates/byline-examples/components/length-indicator.tsx +138 -0
- package/dist/templates/byline-examples/components/pill.tsx +38 -0
- package/dist/templates/byline-examples/components/summary-length.tsx +39 -0
- package/dist/templates/byline-examples/fields/available-languages-field.ts +90 -0
- package/dist/templates/byline-examples/fields/lexical-richtext-compact.ts +88 -0
- package/dist/templates/byline-examples/i18n.ts +47 -0
- package/dist/templates/byline-examples/routes.ts +28 -0
- package/dist/templates/byline-examples/scripts/regenerate-media.ts +275 -0
- package/dist/templates/byline-examples/seed.ts +25 -0
- package/dist/templates/byline-examples/seeds/admin.ts +62 -0
- package/dist/templates/byline-examples/seeds/doc-categories.ts +71 -0
- package/dist/templates/byline-examples/seeds/docs.ts +293 -0
- package/dist/templates/byline-examples/seeds/news-categories.ts +71 -0
- package/dist/templates/byline-examples/server.config.ts +179 -0
- package/dist/templates/host/vite.config.ts +41 -0
- package/dist/templates/migrations/0000_condemned_kronos.sql +324 -0
- package/dist/templates/migrations/0001_sudden_phantom_reporter.sql +1 -0
- package/dist/templates/migrations/meta/0000_snapshot.json +2793 -0
- package/dist/templates/migrations/meta/0001_snapshot.json +2799 -0
- package/dist/templates/migrations/meta/_journal.json +20 -0
- package/dist/templates/routes/(byline)/admin/account/index.tsx +11 -0
- package/dist/templates/routes/(byline)/admin/collections/$collection/$id/api.tsx +16 -0
- package/dist/templates/routes/(byline)/admin/collections/$collection/$id/history.tsx +19 -0
- package/dist/templates/routes/(byline)/admin/collections/$collection/$id/index.tsx +16 -0
- package/dist/templates/routes/(byline)/admin/collections/$collection/create.tsx +11 -0
- package/dist/templates/routes/(byline)/admin/collections/$collection/index.tsx +11 -0
- package/dist/templates/routes/(byline)/admin/index.tsx +11 -0
- package/dist/templates/routes/(byline)/admin/permissions/index.tsx +11 -0
- package/dist/templates/routes/(byline)/admin/roles/$id/index.tsx +11 -0
- package/dist/templates/routes/(byline)/admin/roles/index.tsx +11 -0
- package/dist/templates/routes/(byline)/admin/route.tsx +11 -0
- package/dist/templates/routes/(byline)/admin/users/$id/index.tsx +11 -0
- package/dist/templates/routes/(byline)/admin/users/index.tsx +11 -0
- package/dist/templates/routes/(byline)/sign-in.tsx +11 -0
- package/dist/templates/ui-byline/blocks/photo-block/index.tsx +80 -0
- package/dist/templates/ui-byline/blocks/richtext-block/index.tsx +46 -0
- package/dist/templates/ui-byline/components/admonition/index.tsx +40 -0
- package/dist/templates/ui-byline/components/code/code-serializer.tsx +20 -0
- package/dist/templates/ui-byline/components/code/code.tsx +50 -0
- package/dist/templates/ui-byline/components/code/index.module.scss +137 -0
- package/dist/templates/ui-byline/components/code/index.ts +2 -0
- package/dist/templates/ui-byline/components/code/types.ts +5 -0
- package/dist/templates/ui-byline/components/code/utils.ts +20 -0
- package/dist/templates/ui-byline/components/heading-anchor/heading-anchor.tsx +69 -0
- package/dist/templates/ui-byline/components/heading-anchor/index.ts +1 -0
- package/dist/templates/ui-byline/components/heading-anchor/utils.ts +15 -0
- package/dist/templates/ui-byline/components/inline-image/index.tsx +109 -0
- package/dist/templates/ui-byline/components/layout/index.tsx +63 -0
- package/dist/templates/ui-byline/components/link/lang-link.tsx +70 -0
- package/dist/templates/ui-byline/components/link/link-field.tsx +298 -0
- package/dist/templates/ui-byline/components/link/link-lexical.tsx +191 -0
- package/dist/templates/ui-byline/components/list/index.ts +2 -0
- package/dist/templates/ui-byline/components/list/list-item.tsx +32 -0
- package/dist/templates/ui-byline/components/list/list.tsx +17 -0
- package/dist/templates/ui-byline/components/responsive-image/index.tsx +205 -0
- package/dist/templates/ui-byline/components/richtext-lexical/index.tsx +31 -0
- package/dist/templates/ui-byline/components/richtext-lexical/serialize/index.tsx +249 -0
- package/dist/templates/ui-byline/components/richtext-lexical/serialize/richtext-node-formats.ts +66 -0
- package/dist/templates/ui-byline/components/richtext-lexical/serialize/types.ts +48 -0
- package/dist/templates/ui-byline/components/richtext-lexical/serialize/utils.ts +15 -0
- package/dist/templates/ui-byline/components/table-cell/index.tsx +36 -0
- package/dist/templates/ui-byline/components/vimeo/index.tsx +21 -0
- package/dist/templates/ui-byline/components/youtube/index.tsx +22 -0
- package/dist/templates/ui-byline/render-blocks.tsx +71 -0
- package/dist/templates/ui-byline/types/i18n.ts +14 -0
- package/dist/templates/ui-byline/utils/image-sources.ts +102 -0
- package/dist/templates/ui-byline/utils/to-kebab-case.ts +5 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/diff.d.ts +4 -0
- package/dist/ui/diff.d.ts.map +1 -0
- package/dist/ui/diff.js +23 -0
- package/dist/ui/diff.js.map +1 -0
- package/dist/ui/grid.d.ts +7 -0
- package/dist/ui/grid.d.ts.map +1 -0
- package/dist/ui/grid.js +24 -0
- package/dist/ui/grid.js.map +1 -0
- package/dist/ui/logger.d.ts +14 -0
- package/dist/ui/logger.d.ts.map +1 -0
- package/dist/ui/logger.js +30 -0
- package/dist/ui/logger.js.map +1 -0
- package/dist/ui/snippet.d.ts +2 -0
- package/dist/ui/snippet.d.ts.map +1 -0
- package/dist/ui/snippet.js +7 -0
- package/dist/ui/snippet.js.map +1 -0
- 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
|
+
}
|
|
File without changes
|
|
@@ -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>
|