@byline/cli 1.7.3 → 1.7.5
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/dist/templates/byline-examples/collections/media/components/media-list-view.module.css +273 -0
- package/dist/templates/byline-examples/collections/media/components/media-list-view.tsx +39 -53
- package/dist/templates/byline-examples/collections/media/components/media-thumbnail.module.css +35 -0
- package/dist/templates/byline-examples/collections/media/components/media-thumbnail.tsx +5 -11
- package/dist/templates/byline-examples/components/summary-length.module.css +21 -0
- package/dist/templates/byline-examples/components/summary-length.tsx +3 -4
- package/dist/templates/ui-byline/render-blocks.tsx +33 -38
- package/package.json +1 -1
package/dist/templates/byline-examples/collections/media/components/media-list-view.module.css
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MediaListView — card-grid list view for the Media collection.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
.header {
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
gap: 0.75rem;
|
|
9
|
+
padding-top: 0.125rem;
|
|
10
|
+
padding-bottom: 0.125rem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.heading {
|
|
14
|
+
margin: 0 !important;
|
|
15
|
+
padding-bottom: 0.125rem;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.create-link {
|
|
19
|
+
margin-left: auto;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.stats {
|
|
23
|
+
display: inline-flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
justify-content: center;
|
|
26
|
+
height: 1.75rem;
|
|
27
|
+
min-width: 1.75rem;
|
|
28
|
+
padding: 0.3125rem 0.375rem;
|
|
29
|
+
margin-bottom: -0.25rem;
|
|
30
|
+
white-space: nowrap;
|
|
31
|
+
font-size: var(--font-size-sm);
|
|
32
|
+
line-height: 0;
|
|
33
|
+
background-color: var(--gray-25);
|
|
34
|
+
border: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
|
|
35
|
+
border-radius: var(--border-radius-sm);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
:is([data-theme="dark"], :global(.dark)) .stats {
|
|
39
|
+
background-color: var(--canvas-700);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.toolbar {
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-direction: column;
|
|
45
|
+
gap: 0.5rem;
|
|
46
|
+
align-items: flex-start;
|
|
47
|
+
margin-top: 0.75rem;
|
|
48
|
+
margin-bottom: 1rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@media (min-width: 40rem) {
|
|
52
|
+
.toolbar {
|
|
53
|
+
flex-direction: row;
|
|
54
|
+
flex-wrap: wrap;
|
|
55
|
+
align-items: center;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.search {
|
|
60
|
+
width: 100%;
|
|
61
|
+
max-width: 21.875rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.order-group {
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 0.5rem;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@media (min-width: 40rem) {
|
|
71
|
+
.order-group {
|
|
72
|
+
margin-left: auto;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.order-label {
|
|
77
|
+
font-size: var(--font-size-sm);
|
|
78
|
+
white-space: nowrap;
|
|
79
|
+
color: var(--gray-500);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
:is([data-theme="dark"], :global(.dark)) .order-label {
|
|
83
|
+
color: var(--gray-400);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.empty-state {
|
|
87
|
+
display: flex;
|
|
88
|
+
flex-direction: column;
|
|
89
|
+
align-items: center;
|
|
90
|
+
justify-content: center;
|
|
91
|
+
padding-top: 5rem;
|
|
92
|
+
padding-bottom: 5rem;
|
|
93
|
+
color: var(--gray-500);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
:is([data-theme="dark"], :global(.dark)) .empty-state {
|
|
97
|
+
color: var(--gray-400);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.empty-loader {
|
|
101
|
+
margin-bottom: 1rem;
|
|
102
|
+
opacity: 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.empty-text {
|
|
106
|
+
font-size: var(--font-size-sm);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.grid {
|
|
110
|
+
display: grid;
|
|
111
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
112
|
+
gap: 1rem;
|
|
113
|
+
margin-top: 1rem;
|
|
114
|
+
margin-bottom: 1.5rem;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@media (min-width: 40rem) {
|
|
118
|
+
.grid {
|
|
119
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@media (min-width: 48rem) {
|
|
124
|
+
.grid {
|
|
125
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@media (min-width: 77rem) {
|
|
130
|
+
.grid {
|
|
131
|
+
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.card {
|
|
136
|
+
display: flex;
|
|
137
|
+
flex-direction: column;
|
|
138
|
+
overflow: hidden;
|
|
139
|
+
border: var(--border-width-thin) var(--border-style-solid) var(--gray-200);
|
|
140
|
+
border-radius: var(--border-radius-sm);
|
|
141
|
+
background-color: white;
|
|
142
|
+
text-decoration: none;
|
|
143
|
+
transition: border-color 0.15s ease;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.card:hover {
|
|
147
|
+
border-color: var(--color-indigo-400);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
:is([data-theme="dark"], :global(.dark)) .card {
|
|
151
|
+
border-color: var(--gray-700);
|
|
152
|
+
background-color: var(--canvas-800);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
:is([data-theme="dark"], :global(.dark)) .card:hover {
|
|
156
|
+
border-color: var(--color-indigo-500);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.thumb-wrap {
|
|
160
|
+
position: relative;
|
|
161
|
+
aspect-ratio: 5 / 4;
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
background-color: var(--gray-100);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
:is([data-theme="dark"], :global(.dark)) .thumb-wrap {
|
|
167
|
+
background-color: var(--canvas-700);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.thumb-img {
|
|
171
|
+
width: 100%;
|
|
172
|
+
height: 100%;
|
|
173
|
+
object-fit: cover;
|
|
174
|
+
transition: transform 0.2s ease;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.card:hover .thumb-img {
|
|
178
|
+
transform: scale(1.05);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.thumb-placeholder {
|
|
182
|
+
display: flex;
|
|
183
|
+
align-items: center;
|
|
184
|
+
justify-content: center;
|
|
185
|
+
width: 100%;
|
|
186
|
+
height: 100%;
|
|
187
|
+
font-size: var(--font-size-xs);
|
|
188
|
+
color: var(--gray-400);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
:is([data-theme="dark"], :global(.dark)) .thumb-placeholder {
|
|
192
|
+
color: var(--gray-600);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.card-meta {
|
|
196
|
+
display: flex;
|
|
197
|
+
flex-direction: column;
|
|
198
|
+
gap: 0.375rem;
|
|
199
|
+
padding: 0.5rem;
|
|
200
|
+
min-width: 0;
|
|
201
|
+
background-color: white;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
:is([data-theme="dark"], :global(.dark)) .card-meta {
|
|
205
|
+
background-color: var(--canvas-800);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.card-title {
|
|
209
|
+
overflow: hidden;
|
|
210
|
+
text-overflow: ellipsis;
|
|
211
|
+
white-space: nowrap;
|
|
212
|
+
font-size: var(--font-size-sm);
|
|
213
|
+
font-weight: var(--font-weight-medium);
|
|
214
|
+
line-height: 1.375;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.card-meta-row {
|
|
218
|
+
display: flex;
|
|
219
|
+
flex-wrap: wrap;
|
|
220
|
+
align-items: center;
|
|
221
|
+
justify-content: space-between;
|
|
222
|
+
gap: 0.25rem;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.badges {
|
|
226
|
+
display: flex;
|
|
227
|
+
flex-wrap: wrap;
|
|
228
|
+
align-items: center;
|
|
229
|
+
gap: 0.25rem;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.updated-at {
|
|
233
|
+
margin-left: auto;
|
|
234
|
+
font-size: var(--font-size-xs);
|
|
235
|
+
color: var(--gray-500);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
:is([data-theme="dark"], :global(.dark)) .updated-at {
|
|
239
|
+
color: var(--gray-400);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.status-badge {
|
|
243
|
+
display: inline-flex;
|
|
244
|
+
align-items: center;
|
|
245
|
+
border-radius: var(--border-radius-full);
|
|
246
|
+
padding: 0.125rem 0.5rem;
|
|
247
|
+
font-size: var(--font-size-xs);
|
|
248
|
+
font-weight: var(--font-weight-medium);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.status-published {
|
|
252
|
+
color: var(--color-emerald-400);
|
|
253
|
+
background-color: color-mix(in oklab, var(--color-emerald-500) 15%, transparent);
|
|
254
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--color-emerald-500) 30%, transparent);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.status-archived {
|
|
258
|
+
color: var(--color-gray-400);
|
|
259
|
+
background-color: color-mix(in oklab, var(--color-gray-500) 15%, transparent);
|
|
260
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--color-gray-500) 30%, transparent);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.status-default {
|
|
264
|
+
color: var(--color-amber-400);
|
|
265
|
+
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
|
|
266
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--color-amber-500) 30%, transparent);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.bottom-pager {
|
|
270
|
+
display: flex;
|
|
271
|
+
justify-content: flex-end;
|
|
272
|
+
margin-bottom: 1.25rem;
|
|
273
|
+
}
|
|
@@ -35,16 +35,20 @@ import {
|
|
|
35
35
|
Select,
|
|
36
36
|
} from '@byline/ui/react'
|
|
37
37
|
|
|
38
|
+
import styles from './media-list-view.module.css'
|
|
38
39
|
import { FormatBadge } from './media-thumbnail'
|
|
39
40
|
|
|
40
|
-
function formatNumber(
|
|
41
|
-
if (typeof
|
|
41
|
+
export function formatNumber(number: number, decimalPlaces: number) {
|
|
42
|
+
if (typeof number !== 'number' || Number.isNaN(number)) {
|
|
42
43
|
throw new TypeError('Input must be a valid number')
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
|
|
46
|
+
const options = {
|
|
47
|
+
minimumFractionDigits: decimalPlaces !== undefined ? decimalPlaces : 0,
|
|
48
|
+
maximumFractionDigits: decimalPlaces !== undefined ? decimalPlaces : 20,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return number.toLocaleString('en-US', options)
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
// ---------------------------------------------------------------------------
|
|
@@ -82,11 +86,13 @@ function splitOrderValue(value: OrderValue): { order: string; desc: boolean } {
|
|
|
82
86
|
// ---------------------------------------------------------------------------
|
|
83
87
|
|
|
84
88
|
function Stats({ total }: { total: number }) {
|
|
85
|
-
return (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)
|
|
89
|
+
return <span className={styles.stats}>{formatNumber(total, 0)}</span>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function statusClassName(status: string): string {
|
|
93
|
+
if (status === 'published') return styles['status-published']
|
|
94
|
+
if (status === 'archived') return styles['status-archived']
|
|
95
|
+
return styles['status-default']
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
function StatusBadge({
|
|
@@ -97,26 +103,14 @@ function StatusBadge({
|
|
|
97
103
|
workflowStatuses: WorkflowStatus[]
|
|
98
104
|
}) {
|
|
99
105
|
const label = workflowStatuses.find((s) => s.name === status)?.label ?? status
|
|
100
|
-
|
|
101
|
-
status === 'published'
|
|
102
|
-
? 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30'
|
|
103
|
-
: status === 'archived'
|
|
104
|
-
? 'bg-gray-500/15 text-gray-400 ring-gray-500/30'
|
|
105
|
-
: 'bg-amber-500/15 text-amber-400 ring-amber-500/30'
|
|
106
|
-
return (
|
|
107
|
-
<span
|
|
108
|
-
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${colour}`}
|
|
109
|
-
>
|
|
110
|
-
{label}
|
|
111
|
-
</span>
|
|
112
|
-
)
|
|
106
|
+
return <span className={`${styles['status-badge']} ${statusClassName(status)}`}>{label}</span>
|
|
113
107
|
}
|
|
114
108
|
|
|
115
109
|
function EmptyState() {
|
|
116
110
|
return (
|
|
117
|
-
<div className=
|
|
118
|
-
<LoaderRing className=
|
|
119
|
-
<p className=
|
|
111
|
+
<div className={styles['empty-state']}>
|
|
112
|
+
<LoaderRing className={styles['empty-loader']} size={1} color="transparent" />
|
|
113
|
+
<p className={styles['empty-text']}>No media items found.</p>
|
|
120
114
|
</div>
|
|
121
115
|
)
|
|
122
116
|
}
|
|
@@ -184,14 +178,14 @@ export function MediaListView({
|
|
|
184
178
|
<Section>
|
|
185
179
|
<Container>
|
|
186
180
|
{/* ---- Header ---- */}
|
|
187
|
-
<div className=
|
|
188
|
-
<h1 className=
|
|
181
|
+
<div className={styles.header}>
|
|
182
|
+
<h1 className={styles.heading}>{data.included.collection.labels.plural as string}</h1>
|
|
189
183
|
<Stats total={data.meta.total} />
|
|
190
184
|
<IconButton
|
|
191
185
|
aria-label="Upload New Media"
|
|
192
186
|
render={
|
|
193
187
|
<Link
|
|
194
|
-
className=
|
|
188
|
+
className={styles['create-link']}
|
|
195
189
|
to="/admin/collections/$collection/create"
|
|
196
190
|
params={{ collection: collectionPath }}
|
|
197
191
|
/>
|
|
@@ -202,21 +196,18 @@ export function MediaListView({
|
|
|
202
196
|
</div>
|
|
203
197
|
|
|
204
198
|
{/* ---- Toolbar ---- */}
|
|
205
|
-
<div className=
|
|
199
|
+
<div className={styles.toolbar}>
|
|
206
200
|
<Search
|
|
207
201
|
onSearch={handleOnSearch}
|
|
208
202
|
onClear={handleOnClear}
|
|
209
203
|
inputSize="sm"
|
|
210
204
|
placeholder="Search media…"
|
|
211
|
-
className=
|
|
205
|
+
className={styles.search}
|
|
212
206
|
/>
|
|
213
207
|
|
|
214
208
|
{/* Order-by */}
|
|
215
|
-
<div className=
|
|
216
|
-
<label
|
|
217
|
-
htmlFor="media_order"
|
|
218
|
-
className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap"
|
|
219
|
-
>
|
|
209
|
+
<div className={styles['order-group']}>
|
|
210
|
+
<label htmlFor="media_order" className={styles['order-label']}>
|
|
220
211
|
Order by
|
|
221
212
|
</label>
|
|
222
213
|
<Select
|
|
@@ -244,7 +235,7 @@ export function MediaListView({
|
|
|
244
235
|
{data.docs.length === 0 ? (
|
|
245
236
|
<EmptyState />
|
|
246
237
|
) : (
|
|
247
|
-
<div className=
|
|
238
|
+
<div className={styles.grid}>
|
|
248
239
|
{(data.docs as any[]).map((doc) => {
|
|
249
240
|
const fields = doc.fields ?? {}
|
|
250
241
|
const img = fields.image as StoredFileValue | null | undefined
|
|
@@ -258,41 +249,36 @@ export function MediaListView({
|
|
|
258
249
|
key={doc.id}
|
|
259
250
|
to="/admin/collections/$collection/$id"
|
|
260
251
|
params={{ collection: collectionPath, id: doc.id }}
|
|
261
|
-
className=
|
|
252
|
+
className={styles.card}
|
|
262
253
|
>
|
|
263
254
|
{/* Thumbnail */}
|
|
264
|
-
<div className=
|
|
255
|
+
<div className={styles['thumb-wrap']}>
|
|
265
256
|
{thumbUrl ? (
|
|
266
257
|
<img
|
|
267
258
|
src={thumbUrl}
|
|
268
259
|
alt={fields.altText ?? img?.originalFilename ?? ''}
|
|
269
|
-
className=
|
|
260
|
+
className={styles['thumb-img']}
|
|
270
261
|
loading="lazy"
|
|
271
262
|
/>
|
|
272
263
|
) : (
|
|
273
|
-
<span className=
|
|
274
|
-
No image
|
|
275
|
-
</span>
|
|
264
|
+
<span className={styles['thumb-placeholder']}>No image</span>
|
|
276
265
|
)}
|
|
277
266
|
</div>
|
|
278
267
|
|
|
279
268
|
{/* Card meta */}
|
|
280
|
-
<div className=
|
|
281
|
-
<span
|
|
282
|
-
className="truncate text-sm font-medium leading-snug"
|
|
283
|
-
title={fields.title ?? ''}
|
|
284
|
-
>
|
|
269
|
+
<div className={styles['card-meta']}>
|
|
270
|
+
<span className={styles['card-title']} title={fields.title ?? ''}>
|
|
285
271
|
{fields.title ?? '—'}
|
|
286
272
|
</span>
|
|
287
|
-
<div className=
|
|
288
|
-
<div className=
|
|
273
|
+
<div className={styles['card-meta-row']}>
|
|
274
|
+
<div className={styles.badges}>
|
|
289
275
|
{doc.status && (
|
|
290
276
|
<StatusBadge status={doc.status} workflowStatuses={workflowStatuses} />
|
|
291
277
|
)}
|
|
292
278
|
{img?.imageFormat && <FormatBadge format={img.imageFormat} />}
|
|
293
279
|
</div>
|
|
294
280
|
{updatedAt && (
|
|
295
|
-
<span className=
|
|
281
|
+
<span className={styles['updated-at']}>
|
|
296
282
|
<LocalDateTime value={updatedAt} mode="date" />
|
|
297
283
|
</span>
|
|
298
284
|
)}
|
|
@@ -305,7 +291,7 @@ export function MediaListView({
|
|
|
305
291
|
)}
|
|
306
292
|
|
|
307
293
|
{/* ---- Bottom pagination ---- */}
|
|
308
|
-
<div className=
|
|
294
|
+
<div className={styles['bottom-pager']}>
|
|
309
295
|
<RouterPager
|
|
310
296
|
smoothScrollToTop={true}
|
|
311
297
|
page={data.meta.page}
|
package/dist/templates/byline-examples/collections/media/components/media-thumbnail.module.css
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MediaThumbnail / FormatBadge — list-view thumbnail and image-format pill.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
.format-badge {
|
|
6
|
+
display: inline-flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
border-radius: var(--border-radius-full);
|
|
9
|
+
padding: 0.125rem 0.5rem;
|
|
10
|
+
font-size: var(--font-size-xs);
|
|
11
|
+
font-weight: var(--font-weight-medium);
|
|
12
|
+
color: var(--color-gray-400);
|
|
13
|
+
background-color: color-mix(in oklab, var(--color-gray-500) 10%, transparent);
|
|
14
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--color-gray-500) 20%, transparent);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.placeholder {
|
|
18
|
+
display: inline-flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
justify-content: center;
|
|
21
|
+
width: 4.5rem;
|
|
22
|
+
height: 4.5rem;
|
|
23
|
+
border-radius: var(--border-radius-sm);
|
|
24
|
+
background-color: var(--gray-800);
|
|
25
|
+
color: var(--gray-600);
|
|
26
|
+
font-size: 0.6rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.thumbnail {
|
|
30
|
+
width: 4.5rem;
|
|
31
|
+
height: 4.5rem;
|
|
32
|
+
object-fit: cover;
|
|
33
|
+
border-radius: var(--border-radius-sm);
|
|
34
|
+
border: var(--border-width-thin) var(--border-style-solid) var(--gray-700);
|
|
35
|
+
}
|
|
@@ -8,16 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
import type { FormatterProps, StoredFileValue } from '@byline/core'
|
|
10
10
|
|
|
11
|
+
import styles from './media-thumbnail.module.css'
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
14
|
* FormatBadge renders a muted pill showing the image format (e.g. JPEG, PNG, SVG).
|
|
13
15
|
* Intended for use alongside the status badge in list-view card meta.
|
|
14
16
|
*/
|
|
15
17
|
export function FormatBadge({ format }: { format: string }) {
|
|
16
|
-
return (
|
|
17
|
-
<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">
|
|
18
|
-
{format.toUpperCase()}
|
|
19
|
-
</span>
|
|
20
|
-
)
|
|
18
|
+
return <span className={styles['format-badge']}>{format.toUpperCase()}</span>
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
/**
|
|
@@ -31,11 +29,7 @@ export function MediaThumbnail({ record }: FormatterProps) {
|
|
|
31
29
|
const img = fields.image as StoredFileValue | null | undefined
|
|
32
30
|
|
|
33
31
|
if (!img?.storageUrl) {
|
|
34
|
-
return
|
|
35
|
-
<span className="inline-flex items-center justify-center w-18 h-18 bg-gray-800 rounded text-gray-600 text-[0.6rem]">
|
|
36
|
-
—
|
|
37
|
-
</span>
|
|
38
|
-
)
|
|
32
|
+
return <span className={styles.placeholder}>—</span>
|
|
39
33
|
}
|
|
40
34
|
|
|
41
35
|
const thumbVariant = img.variants?.find((v) => v.name === 'thumbnail')
|
|
@@ -45,7 +39,7 @@ export function MediaThumbnail({ record }: FormatterProps) {
|
|
|
45
39
|
<img
|
|
46
40
|
src={thumbUrl}
|
|
47
41
|
alt={img.originalFilename ?? img.filename}
|
|
48
|
-
className=
|
|
42
|
+
className={styles.thumbnail}
|
|
49
43
|
loading="lazy"
|
|
50
44
|
/>
|
|
51
45
|
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SummaryLength — HelpText slot wrapping LengthIndicator + helpText description.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
.wrap {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
gap: 0.25rem;
|
|
9
|
+
margin-top: 0.5rem;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.help-text {
|
|
13
|
+
margin-bottom: 0.25rem;
|
|
14
|
+
font-size: var(--font-size-sm);
|
|
15
|
+
line-height: 1.25;
|
|
16
|
+
color: var(--gray-500);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
:is([data-theme="dark"], :global(.dark)) .help-text {
|
|
20
|
+
color: var(--gray-400);
|
|
21
|
+
}
|
|
@@ -10,6 +10,7 @@ import type { FieldHelpTextSlotProps, SlotComponent } from '@byline/core'
|
|
|
10
10
|
import { useFieldValue } from '@byline/ui/react'
|
|
11
11
|
|
|
12
12
|
import { LengthIndicator } from './length-indicator'
|
|
13
|
+
import styles from './summary-length.module.css'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Custom HelpText slot component for summary-style textArea fields.
|
|
@@ -29,11 +30,9 @@ export const SummaryLength: SlotComponent<FieldHelpTextSlotProps> = ({ path, hel
|
|
|
29
30
|
const value = useFieldValue<string>(path)
|
|
30
31
|
|
|
31
32
|
return (
|
|
32
|
-
<div className=
|
|
33
|
+
<div className={styles.wrap}>
|
|
33
34
|
<LengthIndicator minLength={100} maxLength={300} text={value} />
|
|
34
|
-
{helpText &&
|
|
35
|
-
<p className="text-sm text-gray-500 dark:text-gray-400 leading-tight mb-1">{helpText}</p>
|
|
36
|
-
)}
|
|
35
|
+
{helpText && <p className={styles['help-text']}>{helpText}</p>}
|
|
37
36
|
</div>
|
|
38
37
|
)
|
|
39
38
|
}
|
|
@@ -6,21 +6,38 @@ import { RichTextBlock as RichTextBlockDef } from '~/blocks/richtext-block'
|
|
|
6
6
|
|
|
7
7
|
import { PhotoBlock } from '@/ui/byline/blocks/photo-block'
|
|
8
8
|
import { RichTextBlock } from '@/ui/byline/blocks/richtext-block'
|
|
9
|
-
import { toKebabCase } from '@/ui/
|
|
10
|
-
import type { Locale } from '@/
|
|
9
|
+
import { toKebabCase } from '@/ui/utils/to-kebab-case'
|
|
10
|
+
import type { Locale } from '@/i18n/i18n-config'
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Registered block schemas. Add a new block here (and a matching `case`
|
|
14
|
-
* in the switch below) to wire it into the front-end renderer.
|
|
15
|
-
*/
|
|
16
12
|
const Blocks = [PhotoBlockDef, RichTextBlockDef] as const
|
|
17
13
|
|
|
18
|
-
/**
|
|
19
|
-
* Discriminated union of every registered block's instance shape. Use
|
|
20
|
-
* this as the `blocks` prop type when calling `RenderBlocks`.
|
|
21
|
-
*/
|
|
22
14
|
export type AnyBlock = BlocksUnion<typeof Blocks>
|
|
23
15
|
|
|
16
|
+
// Mapped type ensures every AnyBlock['_type'] has a registered component.
|
|
17
|
+
// TypeScript errors here if Blocks gains a new type without a matching entry.
|
|
18
|
+
type BlockRegistry = {
|
|
19
|
+
[K in AnyBlock['_type']]: React.ComponentType<{
|
|
20
|
+
id: string
|
|
21
|
+
block: Extract<AnyBlock, { _type: K }>
|
|
22
|
+
lng: Locale
|
|
23
|
+
constrainedLayout?: boolean
|
|
24
|
+
}>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const blockComponents: BlockRegistry = {
|
|
28
|
+
photoBlock: PhotoBlock,
|
|
29
|
+
richTextBlock: RichTextBlock,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Loose alias for the call site — BlockRegistry enforces correctness at
|
|
33
|
+
// definition time; TypeScript can't infer the correlation during the map loop.
|
|
34
|
+
type AnyBlockComponent = React.ComponentType<{
|
|
35
|
+
id: string
|
|
36
|
+
block: AnyBlock
|
|
37
|
+
lng: Locale
|
|
38
|
+
constrainedLayout?: boolean
|
|
39
|
+
}>
|
|
40
|
+
|
|
24
41
|
interface Props {
|
|
25
42
|
blocks: AnyBlock[] | undefined | null
|
|
26
43
|
lng: Locale
|
|
@@ -37,34 +54,12 @@ export function RenderBlocks({
|
|
|
37
54
|
return (
|
|
38
55
|
<>
|
|
39
56
|
{blocks.map((block) => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
block={block}
|
|
47
|
-
lng={lng}
|
|
48
|
-
constrainedLayout={constrainedLayout}
|
|
49
|
-
/>
|
|
50
|
-
</Section>
|
|
51
|
-
)
|
|
52
|
-
case 'richTextBlock':
|
|
53
|
-
return (
|
|
54
|
-
<Section className={toKebabCase(block._type)} key={block._id}>
|
|
55
|
-
<RichTextBlock
|
|
56
|
-
id={block._id}
|
|
57
|
-
block={block}
|
|
58
|
-
lng={lng}
|
|
59
|
-
constrainedLayout={constrainedLayout}
|
|
60
|
-
/>
|
|
61
|
-
</Section>
|
|
62
|
-
)
|
|
63
|
-
default: {
|
|
64
|
-
const _exhaustive: never = block
|
|
65
|
-
return null
|
|
66
|
-
}
|
|
67
|
-
}
|
|
57
|
+
const Block = blockComponents[block._type] as AnyBlockComponent
|
|
58
|
+
return (
|
|
59
|
+
<Section className={toKebabCase(block._type)} key={block._id}>
|
|
60
|
+
<Block id={block._id} block={block} lng={lng} constrainedLayout={constrainedLayout} />
|
|
61
|
+
</Section>
|
|
62
|
+
)
|
|
68
63
|
})}
|
|
69
64
|
</>
|
|
70
65
|
)
|