@byline/cli 1.7.2 → 1.7.4
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 +31 -57
- 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/routes/_byline/route.tsx +3 -1
- 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,18 +35,10 @@ import {
|
|
|
35
35
|
Select,
|
|
36
36
|
} from '@byline/ui/react'
|
|
37
37
|
|
|
38
|
+
import { formatNumber } from '@/utils/utils.general'
|
|
39
|
+
import styles from './media-list-view.module.css'
|
|
38
40
|
import { FormatBadge } from './media-thumbnail'
|
|
39
41
|
|
|
40
|
-
function formatNumber(n: number, decimalPlaces: number): string {
|
|
41
|
-
if (typeof n !== 'number' || Number.isNaN(n)) {
|
|
42
|
-
throw new TypeError('Input must be a valid number')
|
|
43
|
-
}
|
|
44
|
-
return n.toLocaleString('en-US', {
|
|
45
|
-
minimumFractionDigits: decimalPlaces,
|
|
46
|
-
maximumFractionDigits: decimalPlaces,
|
|
47
|
-
})
|
|
48
|
-
}
|
|
49
|
-
|
|
50
42
|
// ---------------------------------------------------------------------------
|
|
51
43
|
// Order-by config
|
|
52
44
|
// ---------------------------------------------------------------------------
|
|
@@ -82,11 +74,13 @@ function splitOrderValue(value: OrderValue): { order: string; desc: boolean } {
|
|
|
82
74
|
// ---------------------------------------------------------------------------
|
|
83
75
|
|
|
84
76
|
function Stats({ total }: { total: number }) {
|
|
85
|
-
return (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)
|
|
77
|
+
return <span className={styles.stats}>{formatNumber(total, 0)}</span>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function statusClassName(status: string): string {
|
|
81
|
+
if (status === 'published') return styles['status-published']
|
|
82
|
+
if (status === 'archived') return styles['status-archived']
|
|
83
|
+
return styles['status-default']
|
|
90
84
|
}
|
|
91
85
|
|
|
92
86
|
function StatusBadge({
|
|
@@ -97,26 +91,14 @@ function StatusBadge({
|
|
|
97
91
|
workflowStatuses: WorkflowStatus[]
|
|
98
92
|
}) {
|
|
99
93
|
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
|
-
)
|
|
94
|
+
return <span className={`${styles['status-badge']} ${statusClassName(status)}`}>{label}</span>
|
|
113
95
|
}
|
|
114
96
|
|
|
115
97
|
function EmptyState() {
|
|
116
98
|
return (
|
|
117
|
-
<div className=
|
|
118
|
-
<LoaderRing className=
|
|
119
|
-
<p className=
|
|
99
|
+
<div className={styles['empty-state']}>
|
|
100
|
+
<LoaderRing className={styles['empty-loader']} size={1} color="transparent" />
|
|
101
|
+
<p className={styles['empty-text']}>No media items found.</p>
|
|
120
102
|
</div>
|
|
121
103
|
)
|
|
122
104
|
}
|
|
@@ -184,14 +166,14 @@ export function MediaListView({
|
|
|
184
166
|
<Section>
|
|
185
167
|
<Container>
|
|
186
168
|
{/* ---- Header ---- */}
|
|
187
|
-
<div className=
|
|
188
|
-
<h1 className=
|
|
169
|
+
<div className={styles.header}>
|
|
170
|
+
<h1 className={styles.heading}>{data.included.collection.labels.plural as string}</h1>
|
|
189
171
|
<Stats total={data.meta.total} />
|
|
190
172
|
<IconButton
|
|
191
173
|
aria-label="Upload New Media"
|
|
192
174
|
render={
|
|
193
175
|
<Link
|
|
194
|
-
className=
|
|
176
|
+
className={styles['create-link']}
|
|
195
177
|
to="/admin/collections/$collection/create"
|
|
196
178
|
params={{ collection: collectionPath }}
|
|
197
179
|
/>
|
|
@@ -202,21 +184,18 @@ export function MediaListView({
|
|
|
202
184
|
</div>
|
|
203
185
|
|
|
204
186
|
{/* ---- Toolbar ---- */}
|
|
205
|
-
<div className=
|
|
187
|
+
<div className={styles.toolbar}>
|
|
206
188
|
<Search
|
|
207
189
|
onSearch={handleOnSearch}
|
|
208
190
|
onClear={handleOnClear}
|
|
209
191
|
inputSize="sm"
|
|
210
192
|
placeholder="Search media…"
|
|
211
|
-
className=
|
|
193
|
+
className={styles.search}
|
|
212
194
|
/>
|
|
213
195
|
|
|
214
196
|
{/* 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
|
-
>
|
|
197
|
+
<div className={styles['order-group']}>
|
|
198
|
+
<label htmlFor="media_order" className={styles['order-label']}>
|
|
220
199
|
Order by
|
|
221
200
|
</label>
|
|
222
201
|
<Select
|
|
@@ -244,7 +223,7 @@ export function MediaListView({
|
|
|
244
223
|
{data.docs.length === 0 ? (
|
|
245
224
|
<EmptyState />
|
|
246
225
|
) : (
|
|
247
|
-
<div className=
|
|
226
|
+
<div className={styles.grid}>
|
|
248
227
|
{(data.docs as any[]).map((doc) => {
|
|
249
228
|
const fields = doc.fields ?? {}
|
|
250
229
|
const img = fields.image as StoredFileValue | null | undefined
|
|
@@ -258,41 +237,36 @@ export function MediaListView({
|
|
|
258
237
|
key={doc.id}
|
|
259
238
|
to="/admin/collections/$collection/$id"
|
|
260
239
|
params={{ collection: collectionPath, id: doc.id }}
|
|
261
|
-
className=
|
|
240
|
+
className={styles.card}
|
|
262
241
|
>
|
|
263
242
|
{/* Thumbnail */}
|
|
264
|
-
<div className=
|
|
243
|
+
<div className={styles['thumb-wrap']}>
|
|
265
244
|
{thumbUrl ? (
|
|
266
245
|
<img
|
|
267
246
|
src={thumbUrl}
|
|
268
247
|
alt={fields.altText ?? img?.originalFilename ?? ''}
|
|
269
|
-
className=
|
|
248
|
+
className={styles['thumb-img']}
|
|
270
249
|
loading="lazy"
|
|
271
250
|
/>
|
|
272
251
|
) : (
|
|
273
|
-
<span className=
|
|
274
|
-
No image
|
|
275
|
-
</span>
|
|
252
|
+
<span className={styles['thumb-placeholder']}>No image</span>
|
|
276
253
|
)}
|
|
277
254
|
</div>
|
|
278
255
|
|
|
279
256
|
{/* Card meta */}
|
|
280
|
-
<div className=
|
|
281
|
-
<span
|
|
282
|
-
className="truncate text-sm font-medium leading-snug"
|
|
283
|
-
title={fields.title ?? ''}
|
|
284
|
-
>
|
|
257
|
+
<div className={styles['card-meta']}>
|
|
258
|
+
<span className={styles['card-title']} title={fields.title ?? ''}>
|
|
285
259
|
{fields.title ?? '—'}
|
|
286
260
|
</span>
|
|
287
|
-
<div className=
|
|
288
|
-
<div className=
|
|
261
|
+
<div className={styles['card-meta-row']}>
|
|
262
|
+
<div className={styles.badges}>
|
|
289
263
|
{doc.status && (
|
|
290
264
|
<StatusBadge status={doc.status} workflowStatuses={workflowStatuses} />
|
|
291
265
|
)}
|
|
292
266
|
{img?.imageFormat && <FormatBadge format={img.imageFormat} />}
|
|
293
267
|
</div>
|
|
294
268
|
{updatedAt && (
|
|
295
|
-
<span className=
|
|
269
|
+
<span className={styles['updated-at']}>
|
|
296
270
|
<LocalDateTime value={updatedAt} mode="date" />
|
|
297
271
|
</span>
|
|
298
272
|
)}
|
|
@@ -305,7 +279,7 @@ export function MediaListView({
|
|
|
305
279
|
)}
|
|
306
280
|
|
|
307
281
|
{/* ---- Bottom pagination ---- */}
|
|
308
|
-
<div className=
|
|
282
|
+
<div className={styles['bottom-pager']}>
|
|
309
283
|
<RouterPager
|
|
310
284
|
smoothScrollToTop={true}
|
|
311
285
|
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
|
}
|
|
@@ -38,11 +38,13 @@ export const Route = createFileRoute('/_byline')({
|
|
|
38
38
|
|
|
39
39
|
function BylineLayout() {
|
|
40
40
|
return (
|
|
41
|
+
<div className="byline-ui flex flex-col flex-1 w-full max-w-full h-full">
|
|
41
42
|
<ToastProvider timeout={5000}>
|
|
42
43
|
<BreadcrumbsProvider>
|
|
43
44
|
<Outlet />
|
|
44
45
|
</BreadcrumbsProvider>
|
|
45
46
|
<ToastViewport position="bottom-right" />
|
|
46
|
-
|
|
47
|
+
</ToastProvider>
|
|
48
|
+
</div>
|
|
47
49
|
)
|
|
48
50
|
}
|
|
@@ -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
|
)
|