@happyvertical/smrt-content 0.43.3 → 0.43.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/AGENTS.md +9 -0
- package/agents/content-list.md +869 -0
- package/dist/content-query.d.ts +310 -0
- package/dist/content-query.d.ts.map +1 -0
- package/dist/contents.d.ts +22 -0
- package/dist/contents.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +672 -4
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +22 -2
- package/dist/smrt-knowledge.json +38 -5
- package/dist/svelte/components/ContentList.svelte +1580 -281
- package/dist/svelte/components/ContentList.svelte.d.ts +54 -2
- package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
- package/dist/svelte/content-list-controller.d.ts +306 -0
- package/dist/svelte/content-list-controller.d.ts.map +1 -0
- package/dist/svelte/content-list-controller.js +921 -0
- package/dist/svelte/content-list-query.d.ts +498 -0
- package/dist/svelte/content-list-query.d.ts.map +1 -0
- package/dist/svelte/content-list-query.js +1294 -0
- package/dist/svelte/content-list-saved-views.d.ts +172 -0
- package/dist/svelte/content-list-saved-views.d.ts.map +1 -0
- package/dist/svelte/content-list-saved-views.js +298 -0
- package/dist/svelte/content-list-url-state.d.ts +211 -0
- package/dist/svelte/content-list-url-state.d.ts.map +1 -0
- package/dist/svelte/content-list-url-state.js +856 -0
- package/dist/svelte/i18n.contribution.d.ts +55 -0
- package/dist/svelte/i18n.contribution.d.ts.map +1 -1
- package/dist/svelte/i18n.contribution.js +57 -0
- package/dist/svelte/index.d.ts +5 -0
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +10 -0
- package/package.json +16 -15
|
@@ -1,31 +1,154 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { DataTableViewState as ContentListDataTableViewState } from '@happyvertical/smrt-ui/data';
|
|
3
|
+
import type { ContentListUrlStateOptions } from '../content-list-url-state.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Optional, router-agnostic URL state (#2452).
|
|
7
|
+
*
|
|
8
|
+
* `ContentList` never imports a router: it reads `params` once during
|
|
9
|
+
* initialization and hands the merged parameters back through `onChange`, so a
|
|
10
|
+
* SvelteKit host calls `replaceState`, a hash router rewrites the fragment, and
|
|
11
|
+
* a test passes a plain `URLSearchParams`.
|
|
12
|
+
*/
|
|
13
|
+
export interface ContentListUrlStateBinding {
|
|
14
|
+
/**
|
|
15
|
+
* Query parameters to restore the view from. Read once, at initialization —
|
|
16
|
+
* later navigation is the host's to drive (re-key the component to re-read).
|
|
17
|
+
*/
|
|
18
|
+
params?: URLSearchParams | string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Receives the full merged parameter set — every foreign parameter
|
|
21
|
+
* preserved — whenever the query-affecting state changes. Never called for
|
|
22
|
+
* the initial restore.
|
|
23
|
+
*/
|
|
24
|
+
onChange?: (
|
|
25
|
+
params: URLSearchParams,
|
|
26
|
+
state: ContentListDataTableViewState,
|
|
27
|
+
) => void;
|
|
28
|
+
/** Prefix and default page size, forwarded to the URL-state module. */
|
|
29
|
+
options?: ContentListUrlStateOptions;
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
1
33
|
<script lang="ts">
|
|
34
|
+
import {
|
|
35
|
+
DataTable,
|
|
36
|
+
type DataTableColumn,
|
|
37
|
+
type DataTableViewState,
|
|
38
|
+
} from '@happyvertical/smrt-ui/data';
|
|
2
39
|
import { ConfirmDialog } from '@happyvertical/smrt-ui/feedback';
|
|
3
|
-
import { Input, Select } from '@happyvertical/smrt-ui/forms';
|
|
40
|
+
import { Checkbox, Input, Select } from '@happyvertical/smrt-ui/forms';
|
|
4
41
|
import { useI18n } from '@happyvertical/smrt-ui/i18n';
|
|
5
|
-
import { Button } from '@happyvertical/smrt-ui/ui';
|
|
42
|
+
import { Button, Pagination } from '@happyvertical/smrt-ui/ui';
|
|
6
43
|
import type { Snippet } from 'svelte';
|
|
7
44
|
import { untrack } from 'svelte';
|
|
8
45
|
import type { ContentData } from '../../mock-smrt-client.js';
|
|
46
|
+
import {
|
|
47
|
+
applyContentListFilter,
|
|
48
|
+
buildContentListColumns,
|
|
49
|
+
buildContentListSurfaceDescriptor,
|
|
50
|
+
CONTENT_LIST_ACTIONS_COLUMN_ID,
|
|
51
|
+
CONTENT_LIST_ROW_KEY,
|
|
52
|
+
CONTENT_LIST_SELECTION_COLUMN_ID,
|
|
53
|
+
CONTENT_LIST_STATUS_FILTER_ID,
|
|
54
|
+
CONTENT_LIST_TYPE_FILTER_ID,
|
|
55
|
+
type ContentListDataSurface,
|
|
56
|
+
type ContentListRow,
|
|
57
|
+
type ContentListViewMode,
|
|
58
|
+
contentListRowActions,
|
|
59
|
+
contentStateVariant,
|
|
60
|
+
contentStatusVariant,
|
|
61
|
+
createContentListController,
|
|
62
|
+
CONTENT_LIST_STATUS_OPTIONS,
|
|
63
|
+
CONTENT_LIST_TYPE_OPTIONS,
|
|
64
|
+
CONTENT_LIST_UNREPRESENTABLE_OPTION,
|
|
65
|
+
type ContentListSelectFilterState,
|
|
66
|
+
isContentListFilterExactly,
|
|
67
|
+
normalizeContentListTypeLock,
|
|
68
|
+
paginateContentListRows,
|
|
69
|
+
readContentListSelectFilter,
|
|
70
|
+
resolveContentHref,
|
|
71
|
+
selectableContentListRowIds,
|
|
72
|
+
selectContentListRows,
|
|
73
|
+
toContentListRows,
|
|
74
|
+
} from '../content-list-controller.js';
|
|
75
|
+
import {
|
|
76
|
+
CONTENT_LIST_QUERY_DEFAULT_PAGE_SIZE,
|
|
77
|
+
type ContentListQueryDrop,
|
|
78
|
+
type ContentListQueryDropReason,
|
|
79
|
+
type ContentListQueryNotices,
|
|
80
|
+
type ContentListQueryRequestOptions,
|
|
81
|
+
type ContentListQuerySource,
|
|
82
|
+
contentListQueryErrorMessage,
|
|
83
|
+
contentListQueryExactTotal,
|
|
84
|
+
contentListQueryRowsToContents,
|
|
85
|
+
contentListQueryTotalValue,
|
|
86
|
+
CONTENT_LIST_QUERY_MAX_OFFSET,
|
|
87
|
+
contentListViewStateToDataQueryRequest,
|
|
88
|
+
readContentListQueryNotices,
|
|
89
|
+
resolveContentListMaxPageSize,
|
|
90
|
+
} from '../content-list-query.js';
|
|
91
|
+
import {
|
|
92
|
+
type ContentListSavedView,
|
|
93
|
+
type ContentListSavedViewStore,
|
|
94
|
+
restoreContentListSavedView,
|
|
95
|
+
toContentListSavedViewInput,
|
|
96
|
+
} from '../content-list-saved-views.js';
|
|
97
|
+
import {
|
|
98
|
+
applyContentListViewState,
|
|
99
|
+
type ContentListStateDrop,
|
|
100
|
+
type ContentListStateDropReason,
|
|
101
|
+
type ContentListStateValidationOptions,
|
|
102
|
+
mergeContentListViewStateIntoSearchParams,
|
|
103
|
+
readContentListViewStateFromSearchParams,
|
|
104
|
+
} from '../content-list-url-state.js';
|
|
9
105
|
import { M } from '../i18n.contribution.js';
|
|
10
106
|
import ImageThumbnail from './ImageThumbnail.svelte';
|
|
11
107
|
|
|
12
108
|
const { t } = useI18n();
|
|
13
109
|
|
|
110
|
+
/** One reported refusal, from a restore or from the query translation. */
|
|
111
|
+
type ContentListDropNotice = ContentListStateDrop | ContentListQueryDrop;
|
|
112
|
+
|
|
14
113
|
interface Props {
|
|
15
114
|
apiBaseUrl?: string;
|
|
16
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Client-side rows. Ignored when `query` is supplied — the server then owns
|
|
117
|
+
* filtering, sorting, and paging, and these rows would be a second, disagreeing
|
|
118
|
+
* source of truth.
|
|
119
|
+
*/
|
|
120
|
+
contents?: ContentData[];
|
|
17
121
|
type?: string;
|
|
18
|
-
defaultViewMode?:
|
|
122
|
+
defaultViewMode?: ContentListViewMode;
|
|
19
123
|
onEdit: (content: ContentData) => void;
|
|
20
124
|
onDelete: (content: ContentData) => void;
|
|
21
125
|
onAdd: () => void;
|
|
22
126
|
controls?: Snippet;
|
|
23
127
|
getViewHref?: (content: ContentData) => string | null;
|
|
128
|
+
/** Announced uniformly by every presentation; #2455 extends it. */
|
|
129
|
+
loading?: boolean;
|
|
130
|
+
/** Load failure announced instead of the list. */
|
|
131
|
+
error?: string | null;
|
|
132
|
+
/** Retry affordance rendered with an error. */
|
|
133
|
+
onRetry?: () => void;
|
|
134
|
+
/** Opt-in agent addressability. Non-table presentations land with #2456. */
|
|
135
|
+
dataSurface?: ContentListDataSurface;
|
|
136
|
+
/**
|
|
137
|
+
* Opt-in server-backed rows (#2452). `bind()` is called exactly once, during
|
|
138
|
+
* initialization, so a `remoteQuery(...)` binding is disposed with this
|
|
139
|
+
* component. Supplying it switches the list into server mode: `contents` is
|
|
140
|
+
* ignored and the local select/paginate transform never runs.
|
|
141
|
+
*/
|
|
142
|
+
query?: ContentListQuerySource;
|
|
143
|
+
/** Opt-in shareable URL state. The host owns navigation. */
|
|
144
|
+
urlState?: ContentListUrlStateBinding;
|
|
145
|
+
/** Opt-in saved views. `createContentListSavedViewStore()` is the default store. */
|
|
146
|
+
savedViews?: ContentListSavedViewStore;
|
|
24
147
|
}
|
|
25
148
|
|
|
26
149
|
let {
|
|
27
150
|
apiBaseUrl = '/api/v1',
|
|
28
|
-
contents,
|
|
151
|
+
contents = [],
|
|
29
152
|
type = undefined,
|
|
30
153
|
defaultViewMode = 'grid',
|
|
31
154
|
onEdit,
|
|
@@ -33,154 +156,1167 @@ let {
|
|
|
33
156
|
onAdd,
|
|
34
157
|
controls,
|
|
35
158
|
getViewHref = undefined,
|
|
159
|
+
loading = false,
|
|
160
|
+
error = null,
|
|
161
|
+
onRetry = undefined,
|
|
162
|
+
dataSurface = undefined,
|
|
163
|
+
query = undefined,
|
|
164
|
+
urlState = undefined,
|
|
165
|
+
savedViews = undefined,
|
|
36
166
|
}: Props = $props();
|
|
37
167
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
168
|
+
const initialQuery = untrack(() => query);
|
|
169
|
+
|
|
170
|
+
// One controller owns search, filters, sorting, paging, and selection for
|
|
171
|
+
// every presentation. The view mode lives beside it, so switching presentation
|
|
172
|
+
// never touches query or selection state.
|
|
173
|
+
// The seed is intentionally the initial `type`; the effect below keeps the
|
|
174
|
+
// locked filter in sync afterwards.
|
|
175
|
+
const initialUrlState = untrack(() => urlState);
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* THE page-size ceiling. Resolved once, from every configured limit, and used
|
|
179
|
+
* by the controller seed, the URL sanitizer, the saved-view sanitizer and the
|
|
180
|
+
* translator — so the size the UI pages by and the size the server applies are
|
|
181
|
+
* the same number by construction rather than by coincidence.
|
|
182
|
+
*
|
|
183
|
+
* Every candidate narrows (`Math.min`, inside `resolveContentListMaxPageSize`):
|
|
184
|
+
* a host that sets `query.request.maxPageSize` as a server row budget must not
|
|
185
|
+
* have it discarded because a looser `urlState.options.maxPageSize` also exists.
|
|
186
|
+
*/
|
|
187
|
+
const maxPageSize = resolveContentListMaxPageSize(
|
|
188
|
+
initialUrlState?.options?.maxPageSize,
|
|
189
|
+
initialQuery?.request?.maxPageSize,
|
|
43
190
|
);
|
|
44
191
|
|
|
45
|
-
|
|
46
|
-
|
|
192
|
+
/**
|
|
193
|
+
* The page size a server-backed list runs at, clamped to that ceiling.
|
|
194
|
+
*
|
|
195
|
+
* `null` (unpaginated) is only expressible locally: the query endpoint always
|
|
196
|
+
* applies a limit, so an unpaginated server list would render one silent page
|
|
197
|
+
* with no controls. This value is the seed, the URL layer's notion of the
|
|
198
|
+
* default (so a link without `size` restores it rather than wiping it), and the
|
|
199
|
+
* translator's fallback — one number in all three places. Clamping the seed
|
|
200
|
+
* matters on its own: a `defaultPageSize` above the ceiling would seed a page
|
|
201
|
+
* size the request then silently reduces, and `totalPages` would compute 1.
|
|
202
|
+
*/
|
|
203
|
+
const serverPageSize = initialQuery
|
|
204
|
+
? Math.min(
|
|
205
|
+
Math.max(
|
|
206
|
+
1,
|
|
207
|
+
Math.floor(
|
|
208
|
+
initialQuery.request?.defaultPageSize ??
|
|
209
|
+
CONTENT_LIST_QUERY_DEFAULT_PAGE_SIZE,
|
|
210
|
+
),
|
|
211
|
+
),
|
|
212
|
+
maxPageSize,
|
|
213
|
+
)
|
|
214
|
+
: null;
|
|
215
|
+
|
|
216
|
+
/** Validation options shared by the URL and saved-view restore paths. */
|
|
217
|
+
const restoreOptions: ContentListStateValidationOptions = { maxPageSize };
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* URL options with the server page size filled in as the default, so a link
|
|
221
|
+
* that omits `size` restores the seed instead of the local `null`, and a link
|
|
222
|
+
* this list writes omits `size` while the list is at its default.
|
|
223
|
+
*/
|
|
224
|
+
const urlStateOptions: ContentListUrlStateOptions = {
|
|
225
|
+
...initialUrlState?.options,
|
|
226
|
+
maxPageSize,
|
|
227
|
+
...(serverPageSize !== null &&
|
|
228
|
+
initialUrlState?.options?.defaultPageSize === undefined
|
|
229
|
+
? { defaultPageSize: serverPageSize }
|
|
230
|
+
: {}),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const controller = createContentListController({
|
|
234
|
+
type: untrack(() => type),
|
|
235
|
+
// Local mode keeps the historical unpaginated list.
|
|
236
|
+
...(serverPageSize === null ? {} : { pageSize: serverPageSize }),
|
|
47
237
|
});
|
|
48
238
|
|
|
49
|
-
|
|
50
|
-
|
|
239
|
+
/** Everything a restore or a translation refused, surfaced as one notice. */
|
|
240
|
+
let restoreDrops = $state<ContentListDropNotice[]>([]);
|
|
241
|
+
let queryDrops = $state<ContentListQueryDrop[]>([]);
|
|
242
|
+
/**
|
|
243
|
+
* A capped-offset redirect, held apart from `queryDrops` so it survives the
|
|
244
|
+
* corrective re-translation that immediately follows it.
|
|
245
|
+
*/
|
|
246
|
+
let pageCapDrop = $state<ContentListQueryDrop | null>(null);
|
|
247
|
+
/** Which page the last offset cap redirected to. Named in the notice. */
|
|
248
|
+
let pageCapCorrectedTo = $state<number | undefined>(undefined);
|
|
249
|
+
let dismissedDropKey = $state('');
|
|
250
|
+
let resultNotices = $state<ContentListQueryNotices>({
|
|
251
|
+
truncated: false,
|
|
252
|
+
warnings: [],
|
|
253
|
+
});
|
|
254
|
+
/**
|
|
255
|
+
* The query signature the binding's rows and total currently describe.
|
|
256
|
+
*
|
|
257
|
+
* A total is only authoritative for the query that produced it. `remoteQuery`
|
|
258
|
+
* keeps serving the PREVIOUS query's total while a new request is in flight, so
|
|
259
|
+
* a saved view or a programmatic patch that changes the query AND restores a
|
|
260
|
+
* page past the old query's last one would have that page clamped away before
|
|
261
|
+
* its own total ever arrived.
|
|
262
|
+
*/
|
|
263
|
+
let settledSignature = $state<string | undefined>(undefined);
|
|
264
|
+
|
|
265
|
+
function toSearchParams(
|
|
266
|
+
input: URLSearchParams | string | null | undefined,
|
|
267
|
+
): URLSearchParams {
|
|
268
|
+
if (!input) return new URLSearchParams();
|
|
269
|
+
return new URLSearchParams(input);
|
|
51
270
|
}
|
|
52
271
|
|
|
53
|
-
|
|
54
|
-
|
|
272
|
+
// The URL restore runs before the first snapshot is taken, so the initial
|
|
273
|
+
// render is already the restored view rather than a flash of the default one.
|
|
274
|
+
// `applyContentListViewState` merges over current state instead of dispatching
|
|
275
|
+
// `setSearch`/`setFilters`, which would reset the restored page.
|
|
276
|
+
// An empty query string is a valid binding, not an absent one: the reader
|
|
277
|
+
// still applies the configured defaults, so a page opened at a bare path must
|
|
278
|
+
// restore the same way one opened with parameters does.
|
|
279
|
+
if (initialUrlState?.params !== undefined && initialUrlState?.params !== null) {
|
|
280
|
+
const reading = readContentListViewStateFromSearchParams(
|
|
281
|
+
toSearchParams(initialUrlState.params),
|
|
282
|
+
urlStateOptions,
|
|
283
|
+
);
|
|
284
|
+
// A restore REPLACES the filter set, including the type filter the controller
|
|
285
|
+
// was seeded with. On a locked list the lock effect would then re-apply it
|
|
286
|
+
// through `setFilters`, which resets paging — silently discarding a `?page=`
|
|
287
|
+
// the same link just restored. Folding the lock into the restored patch keeps
|
|
288
|
+
// it to one `replaceState`, and leaves the effect's first run a no-op.
|
|
289
|
+
const initialLockedType = normalizeContentListTypeLock(untrack(() => type));
|
|
290
|
+
const patch =
|
|
291
|
+
initialLockedType === null
|
|
292
|
+
? reading.state
|
|
293
|
+
: {
|
|
294
|
+
...reading.state,
|
|
295
|
+
filters: [
|
|
296
|
+
...(reading.state.filters ?? []).filter(
|
|
297
|
+
(filter) => filter.columnId !== CONTENT_LIST_TYPE_FILTER_ID,
|
|
298
|
+
),
|
|
299
|
+
{
|
|
300
|
+
columnId: CONTENT_LIST_TYPE_FILTER_ID,
|
|
301
|
+
operator: 'equals' as const,
|
|
302
|
+
value: initialLockedType,
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
};
|
|
306
|
+
applyContentListViewState(controller, patch, restoreOptions);
|
|
307
|
+
restoreDrops = reading.dropped;
|
|
55
308
|
}
|
|
56
309
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
310
|
+
/**
|
|
311
|
+
* The server-query binding, created once. `bind()` runs inside this
|
|
312
|
+
* component's initialization, so a binding that registers an `$effect` teardown
|
|
313
|
+
* (as `remoteQuery` does) is disposed when this component is.
|
|
314
|
+
*/
|
|
315
|
+
const queryBinding = initialQuery?.bind();
|
|
316
|
+
const queryRequestOptions: ContentListQueryRequestOptions | undefined =
|
|
317
|
+
initialQuery
|
|
318
|
+
? {
|
|
319
|
+
...initialQuery.request,
|
|
320
|
+
maxPageSize,
|
|
321
|
+
...(serverPageSize === null ? {} : { defaultPageSize: serverPageSize }),
|
|
322
|
+
}
|
|
323
|
+
: undefined;
|
|
324
|
+
const serverBacked = queryBinding !== undefined;
|
|
325
|
+
|
|
326
|
+
let snapshot = $state(controller.snapshot());
|
|
327
|
+
let viewMode: ContentListViewMode = $state(untrack(() => defaultViewMode));
|
|
328
|
+
let pendingDelete = $state<ContentListRow | null>(null);
|
|
329
|
+
let savedViewList = $state<ContentListSavedView[]>([]);
|
|
330
|
+
let selectedSavedViewId = $state('');
|
|
331
|
+
let savedViewName = $state('');
|
|
332
|
+
|
|
333
|
+
$effect(() =>
|
|
334
|
+
controller.subscribe((transition) => {
|
|
335
|
+
snapshot = transition.next;
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const tableState = $derived(snapshot.state);
|
|
340
|
+
|
|
341
|
+
// In server mode the controller's page size must be a number the request can
|
|
342
|
+
// actually carry, or `totalPages` and `showPagination` describe a page the
|
|
343
|
+
// server never returned and rows are stranded behind a plausible-looking single
|
|
344
|
+
// page. Two ways in, both enforced against LIVE state because a saved view, a
|
|
345
|
+
// link, and a data-surface `set-page-size` all arrive after mount:
|
|
346
|
+
//
|
|
347
|
+
// null → unpaginated, which the endpoint cannot express;
|
|
348
|
+
// > maxPageSize → the translator clamps the request, so leaving the
|
|
349
|
+
// controller above the ceiling makes the two disagree.
|
|
350
|
+
//
|
|
351
|
+
// `setPageSize` also resets the page, which is correct: a different page size
|
|
352
|
+
// means the old page number addresses different rows.
|
|
353
|
+
$effect(() => {
|
|
354
|
+
if (serverPageSize === null) return;
|
|
355
|
+
const current = tableState.pageSize;
|
|
356
|
+
if (current !== null && current <= maxPageSize) return;
|
|
357
|
+
const next = current === null ? serverPageSize : maxPageSize;
|
|
358
|
+
const reason: ContentListQueryDropReason =
|
|
359
|
+
current === null ? 'unpaginated-unsupported' : 'out-of-range';
|
|
360
|
+
untrack(() => {
|
|
361
|
+
controller.dispatch({ type: 'setPageSize', pageSize: next });
|
|
362
|
+
restoreDrops = [
|
|
363
|
+
...restoreDrops.filter(
|
|
364
|
+
(drop) =>
|
|
365
|
+
drop.scope !== 'pageSize' ||
|
|
366
|
+
(drop.reason !== 'unpaginated-unsupported' &&
|
|
367
|
+
drop.reason !== 'out-of-range'),
|
|
368
|
+
),
|
|
369
|
+
{
|
|
370
|
+
scope: 'pageSize',
|
|
371
|
+
reason,
|
|
372
|
+
...(current === null ? {} : { detail: String(current) }),
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
/** The normalized type the `type` prop locks the list to, if any. */
|
|
379
|
+
const lockedType = $derived(normalizeContentListTypeLock(type));
|
|
380
|
+
|
|
381
|
+
// A `type` prop locks the type filter, exactly as the legacy select did. The
|
|
382
|
+
// lock is enforced against the live state, not only against the prop, because a
|
|
383
|
+
// data-surface `set-filters` or `reset` command can otherwise replace or clear
|
|
384
|
+
// it. The equality guard keeps the effect from dispatching in a loop.
|
|
385
|
+
// Tracks the previous prop so an unlocked list can tell "the lock was just
|
|
386
|
+
// removed" from "there was never a lock". A plain binding, so writing it here
|
|
387
|
+
// cannot re-trigger the effect.
|
|
388
|
+
let previousLockedType: string | null = untrack(() => lockedType);
|
|
389
|
+
|
|
390
|
+
$effect(() => {
|
|
391
|
+
const locked = lockedType;
|
|
392
|
+
if (locked === null) {
|
|
393
|
+
const lockWasRemoved = previousLockedType !== null;
|
|
394
|
+
previousLockedType = null;
|
|
395
|
+
// Unlocked: the toolbar select owns the filter. Clear it only when the prop
|
|
396
|
+
// actually went away, because clearing on every run would also discard a
|
|
397
|
+
// type filter restored from a link or a saved view.
|
|
398
|
+
if (!lockWasRemoved) return;
|
|
399
|
+
untrack(() =>
|
|
400
|
+
applyContentListFilter(controller, CONTENT_LIST_TYPE_FILTER_ID, null),
|
|
401
|
+
);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
previousLockedType = locked;
|
|
405
|
+
if (
|
|
406
|
+
isContentListFilterExactly(tableState, CONTENT_LIST_TYPE_FILTER_ID, locked)
|
|
407
|
+
)
|
|
408
|
+
return;
|
|
409
|
+
untrack(() =>
|
|
410
|
+
applyContentListFilter(controller, CONTENT_LIST_TYPE_FILTER_ID, locked),
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const columnLabels = $derived({
|
|
415
|
+
type: t(M['content.content_list.column_type']),
|
|
416
|
+
title: t(M['content.content_list.column_title']),
|
|
417
|
+
author: t(M['content.content_list.column_author']),
|
|
418
|
+
status: t(M['content.content_list.column_status']),
|
|
419
|
+
state: t(M['content.content_list.column_state']),
|
|
420
|
+
publish: t(M['content.content_list.column_publish']),
|
|
421
|
+
updated: t(M['content.content_list.column_updated']),
|
|
422
|
+
site: t(M['content.content_list.column_site']),
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const queryColumns = $derived(buildContentListColumns(columnLabels));
|
|
426
|
+
const sourceContents = $derived(
|
|
427
|
+
queryBinding ? contentListQueryRowsToContents(queryBinding.rows) : contents,
|
|
428
|
+
);
|
|
429
|
+
const rows = $derived(toContentListRows(sourceContents));
|
|
430
|
+
// In server mode the returned rows ARE the answer: the server already applied
|
|
431
|
+
// the search, the filters, the sort, and the page. Running the local transform
|
|
432
|
+
// over them again would re-filter with subtly different semantics (untrimmed
|
|
433
|
+
// search, case-insensitive comparison, a `site` predicate the server never saw)
|
|
434
|
+
// and could hide rows the server deliberately returned.
|
|
435
|
+
const queryRows = $derived(
|
|
436
|
+
serverBacked ? rows : selectContentListRows(rows, tableState, queryColumns),
|
|
437
|
+
);
|
|
438
|
+
const pageRows = $derived(
|
|
439
|
+
serverBacked ? rows : paginateContentListRows(queryRows, tableState),
|
|
440
|
+
);
|
|
441
|
+
/** Server row count for the whole query, not just the rendered page. */
|
|
442
|
+
const serverTotal = $derived(
|
|
443
|
+
queryBinding ? contentListQueryTotalValue(queryBinding.total) : undefined,
|
|
444
|
+
);
|
|
445
|
+
const totalRowCount = $derived(
|
|
446
|
+
serverBacked ? (serverTotal ?? rows.length) : queryRows.length,
|
|
447
|
+
);
|
|
60
448
|
|
|
61
|
-
|
|
62
|
-
|
|
449
|
+
/**
|
|
450
|
+
* The row count the current page may be clamped against, or `undefined` when
|
|
451
|
+
* no authoritative count exists.
|
|
452
|
+
*
|
|
453
|
+
* Clamping MOVES the operator, so every input has to be judged on whether it is
|
|
454
|
+
* exactly right — twice now a clamp has acted on a number that was not the
|
|
455
|
+
* total. The complete set:
|
|
456
|
+
*
|
|
457
|
+
* | Input | Authoritative? |
|
|
458
|
+
* |---|---|
|
|
459
|
+
* | local mode row count | yes — the supplied array IS the whole result set |
|
|
460
|
+
* | server total, `exact` | yes |
|
|
461
|
+
* | server total, `estimated` | NO — clamping on an approximation can hide a page that really exists |
|
|
462
|
+
* | server total, `unavailable` | NO — the count is unknown, and `rows.length` is the page, not the total |
|
|
463
|
+
* | no response yet for this query | NO — a page restored from a link must survive until its own count arrives |
|
|
464
|
+
* | a settled response for a DIFFERENT query | NO — the binding still holds the previous query's total while a new request is in flight |
|
|
465
|
+
* | a page-size change | n/a — `setPageSize` resets the page itself, so it can never strand an out-of-range one |
|
|
466
|
+
*
|
|
467
|
+
* `pageableRowCount` deliberately keeps using the looser
|
|
468
|
+
* `contentListQueryTotalValue`: an estimate is fine for SHOWING a pager, and
|
|
469
|
+
* over-offering a page is visible and self-correcting where hiding one is not.
|
|
470
|
+
*/
|
|
471
|
+
const clampableRowCount = $derived(
|
|
472
|
+
serverBacked
|
|
473
|
+
? contentListQueryExactTotal(queryBinding?.total)
|
|
474
|
+
: queryRows.length,
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
// The adapter owns filtering, sorting, and paging, so the controller's page has
|
|
478
|
+
// to be clamped against the result count rather than DataTable's. In server
|
|
479
|
+
// mode that count is the server's total — clamping against the page length
|
|
480
|
+
// would collapse every list to a single page.
|
|
481
|
+
$effect(() => {
|
|
482
|
+
const totalRows = clampableRowCount;
|
|
483
|
+
// Both signatures are read as dependencies on purpose: the effect must re-run
|
|
484
|
+
// when the SETTLED query changes even if the new query's count happens to
|
|
485
|
+
// equal the old one's, or a stale page survives on a coincidence.
|
|
486
|
+
const settled = settledSignature;
|
|
487
|
+
const signature = querySignature;
|
|
488
|
+
if (totalRows === undefined) return;
|
|
489
|
+
if (serverBacked && settled !== signature) return;
|
|
490
|
+
// Deliberately the TRUE total, not `pageableRowCount`: clamping to the
|
|
491
|
+
// reachable ceiling here would silently move a crafted `?page=9000` before
|
|
492
|
+
// the query effect ever sees it, and the operator would never be told why
|
|
493
|
+
// they landed somewhere else.
|
|
494
|
+
untrack(() => controller.clampPage(totalRows));
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The last page a server query can actually fetch.
|
|
499
|
+
*
|
|
500
|
+
* Offset paging stops at `CONTENT_LIST_QUERY_MAX_OFFSET`, so on a very large
|
|
501
|
+
* list the arithmetic total implies pages the endpoint will never return.
|
|
502
|
+
* Advertising them is worse than not offering them: every click past the
|
|
503
|
+
* boundary silently lands back on the same page.
|
|
504
|
+
*/
|
|
505
|
+
const maxReachablePage = $derived(
|
|
506
|
+
serverBacked && tableState.pageSize
|
|
507
|
+
? Math.floor(CONTENT_LIST_QUERY_MAX_OFFSET / tableState.pageSize) + 1
|
|
508
|
+
: Number.POSITIVE_INFINITY,
|
|
509
|
+
);
|
|
510
|
+
/**
|
|
511
|
+
* The row count the pagers may page over. DataTable derives its own page count
|
|
512
|
+
* from `totalRows` and takes no ceiling, so the ceiling has to be applied to
|
|
513
|
+
* the number handed to it. `clampPage` deliberately keeps using the true total
|
|
514
|
+
* (see below).
|
|
515
|
+
*/
|
|
516
|
+
const pageableRowCount = $derived(
|
|
517
|
+
tableState.pageSize
|
|
518
|
+
? Math.min(totalRowCount, maxReachablePage * tableState.pageSize)
|
|
519
|
+
: totalRowCount,
|
|
520
|
+
);
|
|
521
|
+
// EVERY presentation renders its own page controls, compact included, and
|
|
522
|
+
// `totalRows` is deliberately never handed to DataTable.
|
|
523
|
+
//
|
|
524
|
+
// DataTable runs its own `clampPage(totalRows)` effect against the SAME
|
|
525
|
+
// controller, with no authority rule and no notion of which query a total
|
|
526
|
+
// belongs to. One prop cannot serve both purposes: it drives that clamp AND
|
|
527
|
+
// DataTable's pager, so any total authoritative enough to clamp against is also
|
|
528
|
+
// the only total the pager can show, and vice versa. Passing an
|
|
529
|
+
// authoritative-only total silences the clamp correctly but leaves compact with
|
|
530
|
+
// no pager on an `estimated` total while the card modes still show one — the
|
|
531
|
+
// two modes would then disagree about which pages exist, which is a worse bug
|
|
532
|
+
// than the one being fixed.
|
|
533
|
+
//
|
|
534
|
+
// So ContentList owns paging outright: one clamp (the effect above, with the
|
|
535
|
+
// authority rule) and one pager (below, driven by `pageableRowCount`, which
|
|
536
|
+
// accepts an estimate because SHOWING a page is a different question from
|
|
537
|
+
// MOVING the operator). The same reasoning already made the selection column
|
|
538
|
+
// content-owned in compact mode.
|
|
539
|
+
const totalPages = $derived(
|
|
540
|
+
tableState.pageSize
|
|
541
|
+
? Math.max(1, Math.ceil(pageableRowCount / tableState.pageSize))
|
|
542
|
+
: 1,
|
|
543
|
+
);
|
|
544
|
+
const showPagination = $derived(Boolean(tableState.pageSize) && totalPages > 1);
|
|
545
|
+
const queryErrorMessage = $derived(
|
|
546
|
+
queryBinding ? contentListQueryErrorMessage(queryBinding.error) : null,
|
|
547
|
+
);
|
|
548
|
+
/** A host-supplied error still wins: it describes the surrounding page load. */
|
|
549
|
+
const activeError = $derived(error ?? queryErrorMessage);
|
|
550
|
+
const isLoading = $derived(loading || (queryBinding?.loading ?? false));
|
|
551
|
+
/** Rows are already rendered, so a load is a refresh rather than a first fill. */
|
|
552
|
+
const refreshing = $derived(
|
|
553
|
+
(queryBinding?.refreshing ?? false) || (isLoading && pageRows.length > 0),
|
|
554
|
+
);
|
|
555
|
+
/**
|
|
556
|
+
* A retry re-reads the same query, so its answer replaces the rendered rows —
|
|
557
|
+
* and therefore has to replace the completeness flags that describe them too.
|
|
558
|
+
* Discarding the envelope here is what left a "rows are missing" notice
|
|
559
|
+
* standing over a page that came back complete (and the inverse after a
|
|
560
|
+
* transient error). The signature does not change on a retry, so the query
|
|
561
|
+
* effect never re-runs and this is the only place that can refresh them.
|
|
562
|
+
*/
|
|
563
|
+
function retryQuery(): void {
|
|
564
|
+
if (!queryBinding) return;
|
|
565
|
+
const signature = executedSignature;
|
|
566
|
+
void queryBinding
|
|
567
|
+
.retry()
|
|
568
|
+
.then((result) => {
|
|
569
|
+
// `retry()` resolves undefined when there is no request to repeat, and a
|
|
570
|
+
// newer query may have superseded this one while it was in flight.
|
|
571
|
+
if (result === undefined || executedSignature !== signature) return;
|
|
572
|
+
resultNotices = readContentListQueryNotices(result);
|
|
573
|
+
})
|
|
574
|
+
.catch(() => undefined);
|
|
63
575
|
}
|
|
64
576
|
|
|
65
|
-
|
|
66
|
-
|
|
577
|
+
const retryHandler = $derived(
|
|
578
|
+
onRetry ?? (queryBinding ? retryQuery : undefined),
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* A signature of exactly the query-affecting state. Selection and expansion are
|
|
583
|
+
* excluded on purpose: checking a row must not re-run the server query. The
|
|
584
|
+
* value is a primitive, so an unrelated transition that leaves the query
|
|
585
|
+
* unchanged does not propagate.
|
|
586
|
+
*/
|
|
587
|
+
const querySignature = $derived(
|
|
588
|
+
JSON.stringify([
|
|
589
|
+
tableState.search,
|
|
590
|
+
tableState.filters,
|
|
591
|
+
tableState.sorting,
|
|
592
|
+
tableState.page,
|
|
593
|
+
tableState.pageSize,
|
|
594
|
+
]),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
let executedSignature: string | undefined;
|
|
598
|
+
let publishedUrlSignature: string | undefined;
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
$effect(() => {
|
|
602
|
+
const signature = querySignature;
|
|
603
|
+
untrack(() => {
|
|
604
|
+
const state = controller.getState();
|
|
605
|
+
if (queryBinding && signature !== executedSignature) {
|
|
606
|
+
executedSignature = signature;
|
|
607
|
+
const translated = contentListViewStateToDataQueryRequest(
|
|
608
|
+
state,
|
|
609
|
+
queryRequestOptions,
|
|
610
|
+
);
|
|
611
|
+
// The page-cap drop is held separately because it must OUTLIVE this
|
|
612
|
+
// translation: the corrective dispatch below re-enters this effect, and
|
|
613
|
+
// the second translation caps nothing, so a drop stored here would be
|
|
614
|
+
// overwritten in the same flush and the redirect would be silent.
|
|
615
|
+
queryDrops = translated.dropped.filter((drop) => drop.scope !== 'page');
|
|
616
|
+
if (translated.effectivePage !== state.page) {
|
|
617
|
+
// The offset had to be capped. Move the controller's page marker to the
|
|
618
|
+
// page the request actually reads, or the UI labels this answer with a
|
|
619
|
+
// page number the server never saw. The dispatch re-enters this effect
|
|
620
|
+
// with the corrected signature, which then executes.
|
|
621
|
+
pageCapDrop = {
|
|
622
|
+
scope: 'page',
|
|
623
|
+
reason: 'out-of-range',
|
|
624
|
+
detail: String(state.page),
|
|
625
|
+
};
|
|
626
|
+
pageCapCorrectedTo = translated.effectivePage;
|
|
627
|
+
controller.dispatch({
|
|
628
|
+
type: 'setPage',
|
|
629
|
+
page: translated.effectivePage,
|
|
630
|
+
});
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
// The redirect notice stands until the operator moves off the page they
|
|
634
|
+
// were redirected to; clearing it on the corrective re-run would be the
|
|
635
|
+
// same silence by a different route.
|
|
636
|
+
if (pageCapDrop !== null && state.page !== pageCapCorrectedTo) {
|
|
637
|
+
pageCapDrop = null;
|
|
638
|
+
pageCapCorrectedTo = undefined;
|
|
639
|
+
}
|
|
640
|
+
// The binding owns cancellation of a superseded run and already reflects
|
|
641
|
+
// a failure in its error state, so a rejection here is not also an
|
|
642
|
+
// unhandled one. The resolved envelope carries the server's completeness
|
|
643
|
+
// flags, which the binding itself does not expose.
|
|
644
|
+
void queryBinding
|
|
645
|
+
.execute(translated.request)
|
|
646
|
+
.then((result) => {
|
|
647
|
+
// A newer query may have superseded this one while it was in flight.
|
|
648
|
+
if (executedSignature !== signature) return;
|
|
649
|
+
// The binding's rows and total now describe THIS query, so the page
|
|
650
|
+
// may be clamped against them.
|
|
651
|
+
settledSignature = signature;
|
|
652
|
+
resultNotices = readContentListQueryNotices(result);
|
|
653
|
+
})
|
|
654
|
+
.catch(() => undefined);
|
|
655
|
+
}
|
|
656
|
+
if (publishedUrlSignature === undefined) {
|
|
657
|
+
// The first pass adopts whatever the restore produced without pushing it
|
|
658
|
+
// back at the host as a navigation.
|
|
659
|
+
publishedUrlSignature = signature;
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (signature === publishedUrlSignature) return;
|
|
663
|
+
publishedUrlSignature = signature;
|
|
664
|
+
const binding = urlState;
|
|
665
|
+
if (!binding?.onChange) return;
|
|
666
|
+
binding.onChange(
|
|
667
|
+
mergeContentListViewStateIntoSearchParams(
|
|
668
|
+
toSearchParams(binding.params),
|
|
669
|
+
state,
|
|
670
|
+
urlStateOptions,
|
|
671
|
+
),
|
|
672
|
+
state as DataTableViewState,
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const selectedRowKeys = $derived(
|
|
678
|
+
new Set(tableState.selectedRowIds.map((rowId) => String(rowId))),
|
|
679
|
+
);
|
|
680
|
+
// Only durable rows may be addressed by a selection.
|
|
681
|
+
const identifiedRowKeys = $derived(
|
|
682
|
+
new Set(selectableContentListRowIds(rows).map((rowId) => String(rowId))),
|
|
683
|
+
);
|
|
684
|
+
const selectablePageRowIds = $derived(selectableContentListRowIds(pageRows));
|
|
685
|
+
const allPageSelected = $derived(
|
|
686
|
+
selectablePageRowIds.length > 0 &&
|
|
687
|
+
selectablePageRowIds.every((rowId) => selectedRowKeys.has(String(rowId))),
|
|
688
|
+
);
|
|
689
|
+
const somePageSelected = $derived(
|
|
690
|
+
!allPageSelected &&
|
|
691
|
+
selectablePageRowIds.some((rowId) => selectedRowKeys.has(String(rowId))),
|
|
692
|
+
);
|
|
693
|
+
const selectedCount = $derived(tableState.selectedRowIds.length);
|
|
694
|
+
|
|
695
|
+
/** Synthetic ids the adapter minted for rows that carry no durable identity. */
|
|
696
|
+
const unidentifiedRowKeys = $derived(
|
|
697
|
+
new Set(
|
|
698
|
+
rows.filter((row) => !row.identified).map((row) => String(row.id)),
|
|
699
|
+
),
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
// DataTable's own selection column and data-surface commands can both introduce
|
|
703
|
+
// ids for rows that carry no durable identity. Normalizing here covers every
|
|
704
|
+
// path at once; re-dispatching only on a real difference keeps it settling.
|
|
705
|
+
//
|
|
706
|
+
// In server mode `rows` is only the current page, so membership cannot be the
|
|
707
|
+
// test: it would silently clear the whole selection on every page change. Only
|
|
708
|
+
// the synthetic ids are stripped there, which keeps a selection addressable
|
|
709
|
+
// across pages while still refusing an unaddressable row.
|
|
710
|
+
$effect(() => {
|
|
711
|
+
const selected = tableState.selectedRowIds;
|
|
712
|
+
const durable = selected.filter((rowId) =>
|
|
713
|
+
serverBacked
|
|
714
|
+
? !unidentifiedRowKeys.has(String(rowId))
|
|
715
|
+
: identifiedRowKeys.has(String(rowId)),
|
|
716
|
+
);
|
|
717
|
+
if (durable.length === selected.length) return;
|
|
718
|
+
untrack(() =>
|
|
719
|
+
controller.dispatch({ type: 'setSelectedRows', rowIds: durable }),
|
|
720
|
+
);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* What each toolbar select may display, and whether it can display the live
|
|
725
|
+
* predicate at all.
|
|
726
|
+
*
|
|
727
|
+
* INVARIANT: the select's displayed state either matches the live predicate
|
|
728
|
+
* exactly, or the operator is told it does not. Three states, and all three are
|
|
729
|
+
* reachable from a shared link:
|
|
730
|
+
*
|
|
731
|
+
* - representable and inside the vocabulary — the select shows it, silently;
|
|
732
|
+
* - representable but outside the vocabulary (`?status=embargoed`, or a typo)
|
|
733
|
+
* — the value is rendered as an extra option AND reported, so an empty list
|
|
734
|
+
* always has an explanation;
|
|
735
|
+
* - not representable at all (`?status.in=draft,review`, `?status.isNull=1`,
|
|
736
|
+
* `?status.notEquals=draft`) — the select shows a disabled summary of the
|
|
737
|
+
* real predicate instead of a value it is not applying, and reports it.
|
|
738
|
+
* Choosing any real option replaces every filter on that column, so the
|
|
739
|
+
* operator is never stuck.
|
|
740
|
+
*/
|
|
741
|
+
const typeFilterState = $derived(
|
|
742
|
+
readContentListSelectFilter(tableState, CONTENT_LIST_TYPE_FILTER_ID),
|
|
743
|
+
);
|
|
744
|
+
const statusFilterState = $derived(
|
|
745
|
+
readContentListSelectFilter(tableState, CONTENT_LIST_STATUS_FILTER_ID),
|
|
746
|
+
);
|
|
747
|
+
const selectedType = $derived(
|
|
748
|
+
typeFilterState.representable
|
|
749
|
+
? typeFilterState.value
|
|
750
|
+
: CONTENT_LIST_UNREPRESENTABLE_OPTION,
|
|
751
|
+
);
|
|
752
|
+
const selectedStatus = $derived(
|
|
753
|
+
statusFilterState.representable
|
|
754
|
+
? statusFilterState.value
|
|
755
|
+
: CONTENT_LIST_UNREPRESENTABLE_OPTION,
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
/** A live, representable value the select has no option for. */
|
|
759
|
+
const unlistedType = $derived(
|
|
760
|
+
typeFilterState.representable &&
|
|
761
|
+
typeFilterState.value &&
|
|
762
|
+
!(CONTENT_LIST_TYPE_OPTIONS as readonly string[]).includes(
|
|
763
|
+
typeFilterState.value,
|
|
764
|
+
)
|
|
765
|
+
? typeFilterState.value
|
|
766
|
+
: null,
|
|
767
|
+
);
|
|
768
|
+
const unlistedStatus = $derived(
|
|
769
|
+
statusFilterState.representable &&
|
|
770
|
+
statusFilterState.value &&
|
|
771
|
+
!(CONTENT_LIST_STATUS_OPTIONS as readonly string[]).includes(
|
|
772
|
+
statusFilterState.value,
|
|
773
|
+
)
|
|
774
|
+
? statusFilterState.value
|
|
775
|
+
: null,
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
function columnFilterDrops(
|
|
779
|
+
columnId: string,
|
|
780
|
+
unlisted: string | null,
|
|
781
|
+
filterState: ContentListSelectFilterState,
|
|
782
|
+
): ContentListQueryDrop[] {
|
|
783
|
+
if (unlisted !== null) {
|
|
784
|
+
return [
|
|
785
|
+
{
|
|
786
|
+
scope: 'filter',
|
|
787
|
+
reason: 'unlisted-value',
|
|
788
|
+
columnId,
|
|
789
|
+
detail: unlisted,
|
|
790
|
+
},
|
|
791
|
+
];
|
|
792
|
+
}
|
|
793
|
+
if (!filterState.representable) {
|
|
794
|
+
return [
|
|
795
|
+
{
|
|
796
|
+
scope: 'filter',
|
|
797
|
+
reason: 'unrepresentable-filter',
|
|
798
|
+
columnId,
|
|
799
|
+
detail: filterState.detail ?? '',
|
|
800
|
+
},
|
|
801
|
+
];
|
|
802
|
+
}
|
|
803
|
+
return [];
|
|
67
804
|
}
|
|
68
805
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}),
|
|
806
|
+
const unlistedValueDrops = $derived<ContentListQueryDrop[]>([
|
|
807
|
+
// A locked list renders no type select, so there is nothing to disagree with.
|
|
808
|
+
...(lockedType !== null
|
|
809
|
+
? []
|
|
810
|
+
: columnFilterDrops(
|
|
811
|
+
CONTENT_LIST_TYPE_FILTER_ID,
|
|
812
|
+
unlistedType,
|
|
813
|
+
typeFilterState,
|
|
814
|
+
)),
|
|
815
|
+
...columnFilterDrops(
|
|
816
|
+
CONTENT_LIST_STATUS_FILTER_ID,
|
|
817
|
+
unlistedStatus,
|
|
818
|
+
statusFilterState,
|
|
819
|
+
),
|
|
820
|
+
]);
|
|
821
|
+
|
|
822
|
+
const surfaceOptions = $derived(
|
|
823
|
+
dataSurface
|
|
824
|
+
? {
|
|
825
|
+
registry: dataSurface.registry,
|
|
826
|
+
descriptor:
|
|
827
|
+
dataSurface.descriptor ??
|
|
828
|
+
buildContentListSurfaceDescriptor({ columnLabels }),
|
|
829
|
+
}
|
|
830
|
+
: undefined,
|
|
95
831
|
);
|
|
96
832
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
833
|
+
// ---------------------------------------------------------------------------
|
|
834
|
+
// Restore reporting
|
|
835
|
+
// ---------------------------------------------------------------------------
|
|
836
|
+
|
|
837
|
+
const dropNotices = $derived<ContentListDropNotice[]>([
|
|
838
|
+
...restoreDrops,
|
|
839
|
+
...queryDrops,
|
|
840
|
+
...unlistedValueDrops,
|
|
841
|
+
...(pageCapDrop === null ? [] : [pageCapDrop]),
|
|
842
|
+
]);
|
|
843
|
+
/**
|
|
844
|
+
* What the server said about the completeness of the answer.
|
|
845
|
+
*
|
|
846
|
+
* A binding may expose the flags directly; `remoteQuery` does not, so the
|
|
847
|
+
* component falls back to the envelope its own `execute` resolved. Either way
|
|
848
|
+
* the operator has to be told: the server drops trailing rows to fit its byte
|
|
849
|
+
* budget, and the next page is computed from `page * limit`, so those rows are
|
|
850
|
+
* skipped on the following page too.
|
|
851
|
+
*/
|
|
852
|
+
const queryTruncated = $derived(
|
|
853
|
+
queryBinding?.truncated ?? resultNotices.truncated,
|
|
854
|
+
);
|
|
855
|
+
const queryWarnings = $derived<ReadonlyArray<string>>(
|
|
856
|
+
queryBinding?.warnings ?? resultNotices.warnings,
|
|
857
|
+
);
|
|
858
|
+
/** Identity of the current set of refusals, so a dismissal is not permanent. */
|
|
859
|
+
const dropNoticeKey = $derived(
|
|
860
|
+
dropNotices.length > 0 || queryTruncated || queryWarnings.length > 0
|
|
861
|
+
? JSON.stringify([dropNotices, queryTruncated, queryWarnings])
|
|
862
|
+
: '',
|
|
863
|
+
);
|
|
864
|
+
const showDropNotice = $derived(
|
|
865
|
+
dropNoticeKey !== '' && dropNoticeKey !== dismissedDropKey,
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
const DROP_REASON_MESSAGES: Record<
|
|
869
|
+
ContentListStateDropReason | ContentListQueryDropReason,
|
|
870
|
+
string
|
|
871
|
+
> = {
|
|
872
|
+
'unknown-column': M['content.content_list.drop_unknown_column'],
|
|
873
|
+
'hidden-column': M['content.content_list.drop_hidden_column'],
|
|
874
|
+
'structural-column': M['content.content_list.drop_structural_column'],
|
|
875
|
+
'no-server-field': M['content.content_list.drop_no_server_field'],
|
|
876
|
+
'unsupported-operator': M['content.content_list.drop_unsupported_operator'],
|
|
877
|
+
'unsupported-value': M['content.content_list.drop_unsupported_value'],
|
|
878
|
+
malformed: M['content.content_list.drop_malformed'],
|
|
879
|
+
'out-of-range': M['content.content_list.drop_out_of_range'],
|
|
880
|
+
'filter-widened': M['content.content_list.drop_filter_widened'],
|
|
881
|
+
'unlisted-value': M['content.content_list.drop_unlisted_value'],
|
|
882
|
+
'unrepresentable-filter':
|
|
883
|
+
M['content.content_list.drop_unrepresentable_filter'],
|
|
884
|
+
'unpaginated-unsupported': M['content.content_list.drop_unpaginated'],
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
function dropNoticeText(drop: ContentListDropNotice): string {
|
|
888
|
+
// A capped offset is a redirect, not merely a refused value: the operator
|
|
889
|
+
// needs to know which page they asked for and which one they are looking at.
|
|
890
|
+
if (drop.reason === 'unlisted-value') {
|
|
891
|
+
return t(M['content.content_list.drop_unlisted_value'], {
|
|
892
|
+
value: drop.detail ?? '',
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
if (drop.reason === 'unrepresentable-filter') {
|
|
896
|
+
return t(M['content.content_list.drop_unrepresentable_filter'], {
|
|
897
|
+
target: drop.columnId ?? drop.scope,
|
|
898
|
+
value: drop.detail ?? '',
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
if (drop.scope === 'page' && drop.reason === 'out-of-range') {
|
|
902
|
+
return t(M['content.content_list.drop_page_unreachable'], {
|
|
903
|
+
requested: drop.detail ?? '',
|
|
904
|
+
landed: String(pageCapCorrectedTo ?? ''),
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
return t(M['content.content_list.dropped_item'], {
|
|
908
|
+
target: drop.columnId ?? drop.scope,
|
|
909
|
+
reason: t(DROP_REASON_MESSAGES[drop.reason]),
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function dismissDropNotice() {
|
|
914
|
+
dismissedDropKey = dropNoticeKey;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ---------------------------------------------------------------------------
|
|
918
|
+
// Saved views
|
|
919
|
+
// ---------------------------------------------------------------------------
|
|
920
|
+
|
|
921
|
+
$effect(() => {
|
|
922
|
+
const store = savedViews;
|
|
923
|
+
if (!store) {
|
|
924
|
+
savedViewList = [];
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
let cancelled = false;
|
|
928
|
+
void store
|
|
929
|
+
.list()
|
|
930
|
+
.then((views) => {
|
|
931
|
+
if (!cancelled) savedViewList = views;
|
|
932
|
+
})
|
|
933
|
+
.catch(() => {
|
|
934
|
+
// An unreadable store is an empty store; the list still opens.
|
|
935
|
+
if (!cancelled) savedViewList = [];
|
|
936
|
+
});
|
|
937
|
+
return () => {
|
|
938
|
+
cancelled = true;
|
|
939
|
+
};
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
async function reloadSavedViews() {
|
|
943
|
+
if (!savedViews) return;
|
|
944
|
+
try {
|
|
945
|
+
savedViewList = await savedViews.list();
|
|
946
|
+
} catch {
|
|
947
|
+
savedViewList = [];
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function applySavedView(id: string) {
|
|
952
|
+
selectedSavedViewId = id;
|
|
953
|
+
const view = savedViewList.find((entry) => entry.id === id);
|
|
954
|
+
if (!view) return;
|
|
955
|
+
try {
|
|
956
|
+
// The same validator, AND the same limits, the URL path uses: a stored view
|
|
957
|
+
// must not be a way around a `maxPageSize` the host configured for links. A
|
|
958
|
+
// stale view restores its valid remainder rather than refusing to open.
|
|
959
|
+
const restoration = restoreContentListSavedView(view, restoreOptions);
|
|
960
|
+
applyContentListViewState(controller, restoration.state, restoreOptions);
|
|
961
|
+
restoreDrops = restoration.dropped;
|
|
962
|
+
} catch {
|
|
963
|
+
restoreDrops = [{ scope: 'state', reason: 'malformed' }];
|
|
107
964
|
}
|
|
108
965
|
}
|
|
109
966
|
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
967
|
+
async function saveCurrentView() {
|
|
968
|
+
const store = savedViews;
|
|
969
|
+
const name = savedViewName.trim();
|
|
970
|
+
if (!store || !name) return;
|
|
971
|
+
try {
|
|
972
|
+
const saved = await store.save(
|
|
973
|
+
toContentListSavedViewInput(name, controller.snapshot()),
|
|
974
|
+
);
|
|
975
|
+
savedViewName = '';
|
|
976
|
+
selectedSavedViewId = saved.id;
|
|
977
|
+
await reloadSavedViews();
|
|
978
|
+
} catch {
|
|
979
|
+
// Keep the operator's text so the save can be retried.
|
|
120
980
|
}
|
|
121
981
|
}
|
|
122
982
|
|
|
123
|
-
function
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
983
|
+
async function deleteSelectedView() {
|
|
984
|
+
const store = savedViews;
|
|
985
|
+
const id = selectedSavedViewId;
|
|
986
|
+
if (!store || !id) return;
|
|
987
|
+
try {
|
|
988
|
+
await store.delete(id);
|
|
989
|
+
selectedSavedViewId = '';
|
|
990
|
+
await reloadSavedViews();
|
|
991
|
+
} catch {
|
|
992
|
+
// Nothing to undo; the list is reloaded on the next mount.
|
|
133
993
|
}
|
|
134
994
|
}
|
|
135
995
|
|
|
136
|
-
|
|
996
|
+
function isSelected(row: ContentListRow): boolean {
|
|
997
|
+
return selectedRowKeys.has(String(row.id));
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function toggleRow(row: ContentListRow) {
|
|
1001
|
+
if (!row.identified) return;
|
|
1002
|
+
controller.dispatch({ type: 'toggleRowSelection', rowId: row.id });
|
|
1003
|
+
}
|
|
137
1004
|
|
|
138
|
-
function
|
|
139
|
-
|
|
1005
|
+
function togglePageSelection() {
|
|
1006
|
+
const remaining = tableState.selectedRowIds.filter(
|
|
1007
|
+
(rowId) =>
|
|
1008
|
+
!selectablePageRowIds.some(
|
|
1009
|
+
(pageRowId) => String(pageRowId) === String(rowId),
|
|
1010
|
+
),
|
|
1011
|
+
);
|
|
1012
|
+
controller.dispatch({
|
|
1013
|
+
type: 'setSelectedRows',
|
|
1014
|
+
rowIds: allPageSelected
|
|
1015
|
+
? remaining
|
|
1016
|
+
: [...remaining, ...selectablePageRowIds],
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function clearSelection() {
|
|
1021
|
+
controller.dispatch({ type: 'setSelectedRows', rowIds: [] });
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function handlePageChange(page: number) {
|
|
1025
|
+
controller.dispatch({ type: 'setPage', page });
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function handleSearch(value: string) {
|
|
1029
|
+
controller.dispatch({ type: 'setSearch', search: value });
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function handleFilter(columnId: string, value: string) {
|
|
1033
|
+
applyContentListFilter(controller, columnId, value || null);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function rowActions(row: ContentListRow) {
|
|
1037
|
+
return contentListRowActions(row, { getViewHref });
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function viewHref(row: ContentListRow): string | null {
|
|
1041
|
+
return resolveContentHref(row.content, getViewHref);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function selectRowLabel(row: ContentListRow): string {
|
|
1045
|
+
return isSelected(row)
|
|
1046
|
+
? t(M['content.content_list.deselect_row'], { title: row.title })
|
|
1047
|
+
: t(M['content.content_list.select_row'], { title: row.title });
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function handleDeleteContent(row: ContentListRow) {
|
|
1051
|
+
pendingDelete = row;
|
|
140
1052
|
}
|
|
141
1053
|
|
|
142
1054
|
function confirmDelete() {
|
|
143
1055
|
const target = pendingDelete;
|
|
144
1056
|
pendingDelete = null;
|
|
145
1057
|
if (target) {
|
|
146
|
-
onDelete(target);
|
|
1058
|
+
onDelete(target.content);
|
|
147
1059
|
}
|
|
148
1060
|
}
|
|
149
1061
|
|
|
150
1062
|
function cancelDelete() {
|
|
151
1063
|
pendingDelete = null;
|
|
152
1064
|
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Compact mode renders the shared columns with content-specific cells.
|
|
1068
|
+
*
|
|
1069
|
+
* Selection is a content-owned column rather than DataTable's built-in one:
|
|
1070
|
+
* DataTable has no per-row selection predicate, so its header select-all would
|
|
1071
|
+
* address the synthetic id of an unidentified row, which the normalization
|
|
1072
|
+
* effect then strips — leaving the header permanently indeterminate. Owning the
|
|
1073
|
+
* column keeps compact select-all identical to the card presentations.
|
|
1074
|
+
*/
|
|
1075
|
+
const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
|
|
1076
|
+
{
|
|
1077
|
+
id: CONTENT_LIST_SELECTION_COLUMN_ID,
|
|
1078
|
+
label: t(M['content.content_list.select_all']),
|
|
1079
|
+
role: 'action',
|
|
1080
|
+
align: 'center',
|
|
1081
|
+
width: '3rem',
|
|
1082
|
+
sortable: false,
|
|
1083
|
+
searchable: false,
|
|
1084
|
+
filterable: false,
|
|
1085
|
+
header: selectHeader,
|
|
1086
|
+
cell: selectCell,
|
|
1087
|
+
},
|
|
1088
|
+
...queryColumns.map((column) => {
|
|
1089
|
+
if (column.id === 'type') return { ...column, cell: typeCell };
|
|
1090
|
+
if (column.id === 'title') return { ...column, cell: titleCell };
|
|
1091
|
+
if (column.id === 'status') return { ...column, cell: statusCell };
|
|
1092
|
+
if (column.id === 'state') return { ...column, cell: stateCell };
|
|
1093
|
+
if (column.id === 'publish') return { ...column, cell: publishCell };
|
|
1094
|
+
if (column.id === 'updated') return { ...column, cell: updatedCell };
|
|
1095
|
+
return column;
|
|
1096
|
+
}),
|
|
1097
|
+
{
|
|
1098
|
+
id: CONTENT_LIST_ACTIONS_COLUMN_ID,
|
|
1099
|
+
label: t(M['content.content_list.actions_column']),
|
|
1100
|
+
role: 'action',
|
|
1101
|
+
align: 'right',
|
|
1102
|
+
sortable: false,
|
|
1103
|
+
searchable: false,
|
|
1104
|
+
filterable: false,
|
|
1105
|
+
cell: actionsCell,
|
|
1106
|
+
},
|
|
1107
|
+
]);
|
|
153
1108
|
</script>
|
|
154
1109
|
|
|
1110
|
+
{#snippet tableEmptyState()}
|
|
1111
|
+
<p class="table-empty-state">{t(M['content.content_list.empty'])}</p>
|
|
1112
|
+
{/snippet}
|
|
1113
|
+
|
|
1114
|
+
{#snippet selectHeader()}
|
|
1115
|
+
<Checkbox
|
|
1116
|
+
checked={allPageSelected}
|
|
1117
|
+
indeterminate={somePageSelected}
|
|
1118
|
+
aria-label={t(M['content.content_list.select_all'])}
|
|
1119
|
+
onchange={togglePageSelection}
|
|
1120
|
+
/>
|
|
1121
|
+
{/snippet}
|
|
1122
|
+
|
|
1123
|
+
{#snippet selectCell({ row }: { row: ContentListRow })}
|
|
1124
|
+
<Checkbox
|
|
1125
|
+
checked={isSelected(row)}
|
|
1126
|
+
disabled={!row.identified}
|
|
1127
|
+
aria-label={selectRowLabel(row)}
|
|
1128
|
+
title={row.identified
|
|
1129
|
+
? undefined
|
|
1130
|
+
: t(M['content.content_list.row_not_selectable'])}
|
|
1131
|
+
onchange={() => toggleRow(row)}
|
|
1132
|
+
/>
|
|
1133
|
+
{/snippet}
|
|
1134
|
+
|
|
1135
|
+
{#snippet typeCell({ row }: { row: ContentListRow })}
|
|
1136
|
+
<span class={`type-pill type-pill--${row.type}`}>{row.typeLabel}</span>
|
|
1137
|
+
{/snippet}
|
|
1138
|
+
|
|
1139
|
+
{#snippet titleCell({ row }: { row: ContentListRow })}
|
|
1140
|
+
{#if viewHref(row)}
|
|
1141
|
+
<a class="title-link" href={viewHref(row)}>{row.title}</a>
|
|
1142
|
+
{:else}
|
|
1143
|
+
<strong>{row.title}</strong>
|
|
1144
|
+
{/if}
|
|
1145
|
+
{/snippet}
|
|
1146
|
+
|
|
1147
|
+
{#snippet statusCell({ row }: { row: ContentListRow })}
|
|
1148
|
+
<span class="badge status-{contentStatusVariant(row.status)}">{row.statusLabel}</span>
|
|
1149
|
+
{/snippet}
|
|
1150
|
+
|
|
1151
|
+
{#snippet stateCell({ row }: { row: ContentListRow })}
|
|
1152
|
+
<span class="badge state-{contentStateVariant(row.state)}">{row.stateLabel}</span>
|
|
1153
|
+
{/snippet}
|
|
1154
|
+
|
|
1155
|
+
{#snippet publishCell({ row }: { row: ContentListRow })}
|
|
1156
|
+
{row.publishLabel || '-'}
|
|
1157
|
+
{/snippet}
|
|
1158
|
+
|
|
1159
|
+
{#snippet updatedCell({ row }: { row: ContentListRow })}
|
|
1160
|
+
{row.updatedLabel || '-'}
|
|
1161
|
+
{/snippet}
|
|
1162
|
+
|
|
1163
|
+
{#snippet actionsCell({ row }: { row: ContentListRow })}
|
|
1164
|
+
{@const actions = rowActions(row)}
|
|
1165
|
+
<div class="actions-cell">
|
|
1166
|
+
{#if actions.includes('view')}
|
|
1167
|
+
<a
|
|
1168
|
+
class="icon-btn"
|
|
1169
|
+
href={viewHref(row)}
|
|
1170
|
+
title={t(M['content.content_list.view_published_article'])}
|
|
1171
|
+
aria-label={t(M['content.content_list.view_published_article'])}
|
|
1172
|
+
>
|
|
1173
|
+
<span aria-hidden="true">🔎</span>
|
|
1174
|
+
</a>
|
|
1175
|
+
{/if}
|
|
1176
|
+
{#if actions.includes('edit')}
|
|
1177
|
+
<Button
|
|
1178
|
+
variant="ghost"
|
|
1179
|
+
size="sm"
|
|
1180
|
+
class="icon-btn"
|
|
1181
|
+
type="button"
|
|
1182
|
+
onclick={() => onEdit(row.content)}
|
|
1183
|
+
title={t(M['content.content_list.edit'])}
|
|
1184
|
+
aria-label={t(M['content.content_list.edit'])}
|
|
1185
|
+
>
|
|
1186
|
+
<span aria-hidden="true">✏️</span>
|
|
1187
|
+
</Button>
|
|
1188
|
+
{/if}
|
|
1189
|
+
{#if actions.includes('delete')}
|
|
1190
|
+
<Button
|
|
1191
|
+
variant="ghost"
|
|
1192
|
+
size="sm"
|
|
1193
|
+
class="icon-btn delete-icon"
|
|
1194
|
+
type="button"
|
|
1195
|
+
onclick={() => handleDeleteContent(row)}
|
|
1196
|
+
title={t(M['content.content_list.delete'])}
|
|
1197
|
+
aria-label={t(M['content.content_list.delete'])}
|
|
1198
|
+
>
|
|
1199
|
+
<span aria-hidden="true">🗑️</span>
|
|
1200
|
+
</Button>
|
|
1201
|
+
{/if}
|
|
1202
|
+
</div>
|
|
1203
|
+
{/snippet}
|
|
1204
|
+
|
|
155
1205
|
<div class="content-list-wrapper">
|
|
156
|
-
|
|
1206
|
+
|
|
157
1207
|
<div class="content-controls">
|
|
158
1208
|
<div class="search-filters">
|
|
159
|
-
<Input
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
1209
|
+
<Input
|
|
1210
|
+
type="text"
|
|
1211
|
+
placeholder={t(M['content.content_list.search_placeholder'])}
|
|
1212
|
+
aria-label={t(M['content.content_list.search_label'])}
|
|
1213
|
+
value={tableState.search}
|
|
1214
|
+
oninput={(event: Event) =>
|
|
1215
|
+
handleSearch((event.currentTarget as HTMLInputElement).value)}
|
|
1216
|
+
/>
|
|
1217
|
+
|
|
1218
|
+
{#if !lockedType}
|
|
1219
|
+
<Select
|
|
1220
|
+
aria-label={t(M['content.content_list.filter_type'])}
|
|
1221
|
+
value={selectedType}
|
|
1222
|
+
onchange={(event: Event) =>
|
|
1223
|
+
handleFilter(
|
|
1224
|
+
CONTENT_LIST_TYPE_FILTER_ID,
|
|
1225
|
+
(event.currentTarget as HTMLSelectElement).value,
|
|
1226
|
+
)}
|
|
1227
|
+
>
|
|
1228
|
+
<option value="">{t(M['content.content_list.all_types'])}</option>
|
|
1229
|
+
<option value="article">{t(M['content.content_list.type_articles'])}</option>
|
|
1230
|
+
<option value="document">{t(M['content.content_list.type_documents'])}</option>
|
|
1231
|
+
<option value="mirror">{t(M['content.content_list.type_mirrors'])}</option>
|
|
1232
|
+
{#if unlistedType}
|
|
1233
|
+
<!-- A live filter the vocabulary does not cover; showing it is what
|
|
1234
|
+
keeps the toolbar honest about why the list may be empty. -->
|
|
1235
|
+
<option value={unlistedType}>{unlistedType}</option>
|
|
1236
|
+
{:else if !typeFilterState.representable}
|
|
1237
|
+
<!-- A live predicate no single option can express. Show the
|
|
1238
|
+
predicate rather than a value the query is not applying. -->
|
|
1239
|
+
<option value={CONTENT_LIST_UNREPRESENTABLE_OPTION} disabled>
|
|
1240
|
+
{typeFilterState.detail}
|
|
1241
|
+
</option>
|
|
1242
|
+
{/if}
|
|
167
1243
|
</Select>
|
|
168
1244
|
{/if}
|
|
169
1245
|
|
|
170
|
-
<Select
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
1246
|
+
<Select
|
|
1247
|
+
aria-label={t(M['content.content_list.filter_status'])}
|
|
1248
|
+
value={selectedStatus}
|
|
1249
|
+
onchange={(event: Event) =>
|
|
1250
|
+
handleFilter(
|
|
1251
|
+
CONTENT_LIST_STATUS_FILTER_ID,
|
|
1252
|
+
(event.currentTarget as HTMLSelectElement).value,
|
|
1253
|
+
)}
|
|
1254
|
+
>
|
|
1255
|
+
<option value="">{t(M['content.content_list.all_statuses'])}</option>
|
|
1256
|
+
<option value="published">{t(M['content.content_list.status_published'])}</option>
|
|
1257
|
+
<option value="draft">{t(M['content.content_list.status_draft'])}</option>
|
|
1258
|
+
<option value="review">{t(M['content.content_list.status_review'])}</option>
|
|
1259
|
+
<option value="archived">{t(M['content.content_list.status_archived'])}</option>
|
|
1260
|
+
{#if unlistedStatus}
|
|
1261
|
+
<option value={unlistedStatus}>{unlistedStatus}</option>
|
|
1262
|
+
{:else if !statusFilterState.representable}
|
|
1263
|
+
<option value={CONTENT_LIST_UNREPRESENTABLE_OPTION} disabled>
|
|
1264
|
+
{statusFilterState.detail}
|
|
1265
|
+
</option>
|
|
1266
|
+
{/if}
|
|
175
1267
|
</Select>
|
|
176
|
-
|
|
1268
|
+
|
|
1269
|
+
{#if savedViews}
|
|
1270
|
+
<div class="saved-views" role="group" aria-label={t(M['content.content_list.saved_views'])}>
|
|
1271
|
+
<Select
|
|
1272
|
+
aria-label={t(M['content.content_list.saved_views'])}
|
|
1273
|
+
value={selectedSavedViewId}
|
|
1274
|
+
onchange={(event: Event) =>
|
|
1275
|
+
applySavedView((event.currentTarget as HTMLSelectElement).value)}
|
|
1276
|
+
>
|
|
1277
|
+
<option value="">{t(M['content.content_list.saved_view_none'])}</option>
|
|
1278
|
+
{#each savedViewList as view (view.id)}
|
|
1279
|
+
<option value={view.id}>{view.name}</option>
|
|
1280
|
+
{/each}
|
|
1281
|
+
</Select>
|
|
1282
|
+
<Input
|
|
1283
|
+
type="text"
|
|
1284
|
+
aria-label={t(M['content.content_list.saved_view_name'])}
|
|
1285
|
+
placeholder={t(M['content.content_list.saved_view_name_placeholder'])}
|
|
1286
|
+
value={savedViewName}
|
|
1287
|
+
oninput={(event: Event) => {
|
|
1288
|
+
savedViewName = (event.currentTarget as HTMLInputElement).value;
|
|
1289
|
+
}}
|
|
1290
|
+
/>
|
|
1291
|
+
<Button
|
|
1292
|
+
variant="ghost"
|
|
1293
|
+
size="sm"
|
|
1294
|
+
type="button"
|
|
1295
|
+
disabled={savedViewName.trim().length === 0}
|
|
1296
|
+
onclick={() => void saveCurrentView()}
|
|
1297
|
+
>
|
|
1298
|
+
{t(M['content.content_list.saved_view_save'])}
|
|
1299
|
+
</Button>
|
|
1300
|
+
{#if selectedSavedViewId}
|
|
1301
|
+
<Button
|
|
1302
|
+
variant="ghost"
|
|
1303
|
+
size="sm"
|
|
1304
|
+
type="button"
|
|
1305
|
+
onclick={() => void deleteSelectedView()}
|
|
1306
|
+
>
|
|
1307
|
+
{t(M['content.content_list.saved_view_delete'])}
|
|
1308
|
+
</Button>
|
|
1309
|
+
{/if}
|
|
1310
|
+
</div>
|
|
1311
|
+
{/if}
|
|
1312
|
+
|
|
177
1313
|
{#if controls}
|
|
178
1314
|
{@render controls()}
|
|
179
1315
|
{/if}
|
|
180
1316
|
</div>
|
|
181
|
-
|
|
1317
|
+
|
|
182
1318
|
<div class="actions-group">
|
|
183
|
-
<div class="view-toggles">
|
|
1319
|
+
<div class="view-toggles" role="group" aria-label={t(M['content.content_list.view_mode'])}>
|
|
184
1320
|
<Button
|
|
185
1321
|
variant="ghost"
|
|
186
1322
|
size="sm"
|
|
@@ -191,7 +1327,7 @@ function cancelDelete() {
|
|
|
191
1327
|
aria-label={t(M['content.content_list.grid_view'])}
|
|
192
1328
|
title={t(M['content.content_list.grid_view'])}
|
|
193
1329
|
>
|
|
194
|
-
<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none">
|
|
1330
|
+
<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" aria-hidden="true">
|
|
195
1331
|
<rect x="3" y="3" width="7" height="7"></rect>
|
|
196
1332
|
<rect x="14" y="3" width="7" height="7"></rect>
|
|
197
1333
|
<rect x="14" y="14" width="7" height="7"></rect>
|
|
@@ -208,7 +1344,7 @@ function cancelDelete() {
|
|
|
208
1344
|
aria-label={t(M['content.content_list.detailed_list'])}
|
|
209
1345
|
title={t(M['content.content_list.detailed_list'])}
|
|
210
1346
|
>
|
|
211
|
-
<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none">
|
|
1347
|
+
<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" aria-hidden="true">
|
|
212
1348
|
<line x1="8" y1="6" x2="21" y2="6"></line>
|
|
213
1349
|
<line x1="8" y1="12" x2="21" y2="12"></line>
|
|
214
1350
|
<line x1="8" y1="18" x2="21" y2="18"></line>
|
|
@@ -227,7 +1363,7 @@ function cancelDelete() {
|
|
|
227
1363
|
aria-label={t(M['content.content_list.compact_list'])}
|
|
228
1364
|
title={t(M['content.content_list.compact_list'])}
|
|
229
1365
|
>
|
|
230
|
-
<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none">
|
|
1366
|
+
<svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" aria-hidden="true">
|
|
231
1367
|
<line x1="3" y1="6" x2="21" y2="6"></line>
|
|
232
1368
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
|
233
1369
|
<line x1="3" y1="18" x2="21" y2="18"></line>
|
|
@@ -236,7 +1372,7 @@ function cancelDelete() {
|
|
|
236
1372
|
</div>
|
|
237
1373
|
|
|
238
1374
|
<Button variant="ghost" class="add-button" type="button" onclick={() => onAdd()}>
|
|
239
|
-
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
|
|
1375
|
+
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" aria-hidden="true">
|
|
240
1376
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
241
1377
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
242
1378
|
</svg>
|
|
@@ -245,160 +1381,260 @@ function cancelDelete() {
|
|
|
245
1381
|
</div>
|
|
246
1382
|
</div>
|
|
247
1383
|
|
|
248
|
-
{#if
|
|
249
|
-
<div class="
|
|
250
|
-
{t(M['content.content_list.
|
|
1384
|
+
{#if showDropNotice}
|
|
1385
|
+
<div class="state-notice" role="status">
|
|
1386
|
+
<p class="state-notice__title">{t(M['content.content_list.dropped_title'])}</p>
|
|
1387
|
+
<ul class="state-notice__list">
|
|
1388
|
+
{#each dropNotices as drop, index (index)}
|
|
1389
|
+
<li>{dropNoticeText(drop)}</li>
|
|
1390
|
+
{/each}
|
|
1391
|
+
{#if queryTruncated}
|
|
1392
|
+
<li>{t(M['content.content_list.result_truncated'])}</li>
|
|
1393
|
+
{/if}
|
|
1394
|
+
{#each queryWarnings as warning, index (index)}
|
|
1395
|
+
<li>{warning}</li>
|
|
1396
|
+
{/each}
|
|
1397
|
+
</ul>
|
|
1398
|
+
<Button
|
|
1399
|
+
variant="ghost"
|
|
1400
|
+
size="sm"
|
|
1401
|
+
type="button"
|
|
1402
|
+
class="state-notice__dismiss"
|
|
1403
|
+
onclick={dismissDropNotice}
|
|
1404
|
+
>
|
|
1405
|
+
{t(M['content.content_list.dropped_dismiss'])}
|
|
1406
|
+
</Button>
|
|
251
1407
|
</div>
|
|
252
|
-
{
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
</tr>
|
|
264
|
-
</thead>
|
|
265
|
-
<tbody>
|
|
266
|
-
{#each filteredContents as content (content.id)}
|
|
267
|
-
<tr>
|
|
268
|
-
<td class="type-cell">
|
|
269
|
-
<span class={`type-pill type-pill--${getNormalizedType(content.type)}`}>
|
|
270
|
-
{getTypeLabel(content.type)}
|
|
271
|
-
</span>
|
|
272
|
-
</td>
|
|
273
|
-
<td class="title-cell"><strong>{getDisplayTitle(content)}</strong></td>
|
|
274
|
-
<td>{getDisplayAuthor(content) || '-'}</td>
|
|
275
|
-
<td><span class="badge status-{getStatusBadge(content.status)}">{content.status}</span></td>
|
|
276
|
-
<td><span class="badge state-{getStateBadge(content.state)}">{content.state}</span></td>
|
|
277
|
-
<td class="actions-cell">
|
|
278
|
-
{#if getViewHref?.(content)}
|
|
279
|
-
<a class="icon-btn" href={getViewHref(content) || '#'} title={t(M['content.content_list.view_published_article'])} aria-label={t(M['content.content_list.view_published_article'])}>🔎</a>
|
|
280
|
-
{/if}
|
|
281
|
-
<Button variant="ghost" size="sm" class="icon-btn" type="button" onclick={() => onEdit(content)} title={t(M['content.content_list.edit'])} aria-label={t(M['content.content_list.edit'])}>✏️</Button>
|
|
282
|
-
<Button variant="ghost" size="sm" class="icon-btn delete-icon" type="button" onclick={() => handleDeleteContent(content)} title={t(M['content.content_list.delete'])} aria-label={t(M['content.content_list.delete'])}>🗑️</Button>
|
|
283
|
-
</td>
|
|
284
|
-
</tr>
|
|
285
|
-
{/each}
|
|
286
|
-
</tbody>
|
|
287
|
-
</table>
|
|
1408
|
+
{/if}
|
|
1409
|
+
|
|
1410
|
+
{#if activeError}
|
|
1411
|
+
<div class="state-panel state-panel--error" role="alert">
|
|
1412
|
+
<p class="state-panel__title">{t(M['content.content_list.error_title'])}</p>
|
|
1413
|
+
<p class="state-panel__detail">{activeError}</p>
|
|
1414
|
+
{#if retryHandler}
|
|
1415
|
+
<Button variant="ghost" type="button" class="retry-button" onclick={() => retryHandler?.()}>
|
|
1416
|
+
{t(M['content.content_list.retry'])}
|
|
1417
|
+
</Button>
|
|
1418
|
+
{/if}
|
|
288
1419
|
</div>
|
|
289
|
-
{:else
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
<
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
1420
|
+
{:else}
|
|
1421
|
+
{#if pageRows.length > 0 || selectedCount > 0}
|
|
1422
|
+
<div class="content-selection">
|
|
1423
|
+
{#if viewMode !== 'compact'}
|
|
1424
|
+
<Checkbox
|
|
1425
|
+
checked={allPageSelected}
|
|
1426
|
+
indeterminate={somePageSelected}
|
|
1427
|
+
aria-label={t(M['content.content_list.select_all'])}
|
|
1428
|
+
onchange={togglePageSelection}
|
|
1429
|
+
/>
|
|
1430
|
+
{/if}
|
|
1431
|
+
<span class="content-selection__count" aria-live="polite">
|
|
1432
|
+
{t(M['content.content_list.selection_count'], { count: selectedCount })}
|
|
1433
|
+
</span>
|
|
1434
|
+
{#if selectedCount > 0}
|
|
1435
|
+
<Button variant="ghost" size="sm" type="button" class="clear-selection" onclick={clearSelection}>
|
|
1436
|
+
{t(M['content.content_list.clear_selection'])}
|
|
1437
|
+
</Button>
|
|
1438
|
+
{/if}
|
|
1439
|
+
</div>
|
|
1440
|
+
{/if}
|
|
1441
|
+
|
|
1442
|
+
{#if refreshing && viewMode !== 'compact'}
|
|
1443
|
+
<!-- DataTable announces its own refresh; the card views need their own. -->
|
|
1444
|
+
<p class="content-refreshing" role="status" aria-live="polite">
|
|
1445
|
+
{t(M['content.content_list.refreshing'])}
|
|
1446
|
+
</p>
|
|
1447
|
+
{/if}
|
|
1448
|
+
|
|
1449
|
+
{#if viewMode === 'compact'}
|
|
1450
|
+
<!--
|
|
1451
|
+
The compact table stays mounted for empty and loading results: it owns
|
|
1452
|
+
the mounted data surface, so unmounting it on a zero-row query would
|
|
1453
|
+
unregister the surface and leave an agent unable to undo its own search.
|
|
1454
|
+
-->
|
|
1455
|
+
<div class="content-table-wrapper">
|
|
1456
|
+
<DataTable
|
|
1457
|
+
data={pageRows}
|
|
1458
|
+
columns={tableColumns}
|
|
1459
|
+
rowKey={CONTENT_LIST_ROW_KEY}
|
|
1460
|
+
{controller}
|
|
1461
|
+
sortable
|
|
1462
|
+
agentAddressable
|
|
1463
|
+
loading={isLoading}
|
|
1464
|
+
caption={t(M['content.content_list.table_caption'])}
|
|
1465
|
+
rowLabel={(row: ContentListRow) => row.title}
|
|
1466
|
+
dataSurface={surfaceOptions}
|
|
1467
|
+
empty={tableEmptyState}
|
|
1468
|
+
/>
|
|
1469
|
+
</div>
|
|
1470
|
+
{:else if isLoading && pageRows.length === 0}
|
|
1471
|
+
<div class="state-panel" role="status">
|
|
1472
|
+
{t(M['content.content_list.loading'])}
|
|
1473
|
+
</div>
|
|
1474
|
+
{:else if pageRows.length === 0}
|
|
1475
|
+
<div class="state-panel empty-state">
|
|
1476
|
+
{t(M['content.content_list.empty'])}
|
|
1477
|
+
</div>
|
|
1478
|
+
{:else if viewMode === 'detailed'}
|
|
1479
|
+
<div class="content-detailed">
|
|
1480
|
+
{#each pageRows as row (row.id)}
|
|
1481
|
+
{@const content = row.content}
|
|
1482
|
+
{@const actions = rowActions(row)}
|
|
1483
|
+
<article class="content-row">
|
|
1484
|
+
<div class="content-row__select">
|
|
1485
|
+
<Checkbox
|
|
1486
|
+
checked={isSelected(row)}
|
|
1487
|
+
disabled={!row.identified}
|
|
1488
|
+
aria-label={selectRowLabel(row)}
|
|
1489
|
+
title={row.identified
|
|
1490
|
+
? undefined
|
|
1491
|
+
: t(M['content.content_list.row_not_selectable'])}
|
|
1492
|
+
onchange={() => toggleRow(row)}
|
|
1493
|
+
/>
|
|
301
1494
|
</div>
|
|
302
1495
|
|
|
303
|
-
<
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
{#if content.url || content.fileKey}
|
|
310
|
-
<div class="content-row__links">
|
|
311
|
-
{#if content.url}
|
|
312
|
-
<a href={content.url} target="_blank" rel="noreferrer">
|
|
313
|
-
{t(M['content.content_list.source_material'])}
|
|
314
|
-
</a>
|
|
315
|
-
{/if}
|
|
316
|
-
{#if content.fileKey}
|
|
317
|
-
<span>{content.fileKey}</span>
|
|
1496
|
+
<div class="content-row__main">
|
|
1497
|
+
<div class="content-row__eyebrow">
|
|
1498
|
+
<span class={`type-pill type-pill--${row.type}`}>{row.typeLabel}</span>
|
|
1499
|
+
{#if row.author}
|
|
1500
|
+
<span class="content-row__author">By {row.author}</span>
|
|
318
1501
|
{/if}
|
|
319
1502
|
</div>
|
|
320
|
-
{/if}
|
|
321
|
-
</div>
|
|
322
1503
|
|
|
323
|
-
|
|
324
|
-
<span class="meta-label">Status</span>
|
|
325
|
-
<span class="badge status-{getStatusBadge(content.status)}">{content.status}</span>
|
|
326
|
-
<span class="meta-label">State</span>
|
|
327
|
-
<span class="badge state-{getStateBadge(content.state)}">{content.state}</span>
|
|
328
|
-
</div>
|
|
1504
|
+
<h3>{row.title}</h3>
|
|
329
1505
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
{/each}
|
|
346
|
-
</div>
|
|
347
|
-
{:else}
|
|
348
|
-
<div class="content-{viewMode}">
|
|
349
|
-
{#each filteredContents as content (content.id)}
|
|
350
|
-
<div class="content-card">
|
|
351
|
-
{#if content.thumbnailAssetId}
|
|
352
|
-
<div class="card-thumbnail">
|
|
353
|
-
<ImageThumbnail
|
|
354
|
-
apiBaseUrl={apiBaseUrl}
|
|
355
|
-
assetId={content.thumbnailAssetId}
|
|
356
|
-
/>
|
|
357
|
-
</div>
|
|
358
|
-
{/if}
|
|
359
|
-
<div class="content-header">
|
|
360
|
-
<div class="content-header__eyebrow">
|
|
361
|
-
<span class={`type-pill type-pill--${getNormalizedType(content.type)}`}>
|
|
362
|
-
{getTypeLabel(content.type)}
|
|
363
|
-
</span>
|
|
364
|
-
{#if getDisplayAuthor(content)}
|
|
365
|
-
<div class="author">{getDisplayAuthor(content)}</div>
|
|
1506
|
+
{#if row.description}
|
|
1507
|
+
<p class="content-row__description">{row.description}</p>
|
|
1508
|
+
{/if}
|
|
1509
|
+
|
|
1510
|
+
{#if content.url || content.fileKey}
|
|
1511
|
+
<div class="content-row__links">
|
|
1512
|
+
{#if content.url}
|
|
1513
|
+
<a href={content.url} target="_blank" rel="noreferrer">
|
|
1514
|
+
{t(M['content.content_list.source_material'])}
|
|
1515
|
+
</a>
|
|
1516
|
+
{/if}
|
|
1517
|
+
{#if content.fileKey}
|
|
1518
|
+
<span>{content.fileKey}</span>
|
|
1519
|
+
{/if}
|
|
1520
|
+
</div>
|
|
366
1521
|
{/if}
|
|
367
1522
|
</div>
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
<span class="badge status-{getStatusBadge(content.status)}">{content.status}</span>
|
|
375
|
-
<span class="badge state-{getStateBadge(content.state)}">{content.state}</span>
|
|
1523
|
+
|
|
1524
|
+
<div class="content-row__meta">
|
|
1525
|
+
<span class="meta-label">{t(M['content.content_list.column_status'])}</span>
|
|
1526
|
+
<span class="badge status-{contentStatusVariant(row.status)}">{row.statusLabel}</span>
|
|
1527
|
+
<span class="meta-label">{t(M['content.content_list.column_state'])}</span>
|
|
1528
|
+
<span class="badge state-{contentStateVariant(row.state)}">{row.stateLabel}</span>
|
|
376
1529
|
</div>
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
<div class="content-footer">
|
|
382
|
-
<div class="meta-links">
|
|
383
|
-
{#if content.url}
|
|
384
|
-
<div class="source">Source: <a href={content.url} target="_blank">{content.url}</a></div>
|
|
1530
|
+
|
|
1531
|
+
<div class="content-row__actions">
|
|
1532
|
+
{#if actions.includes('view')}
|
|
1533
|
+
<a href={viewHref(row)} class="quiet-action">{t(M['content.content_list.view_article'])}</a>
|
|
385
1534
|
{/if}
|
|
386
|
-
{#if
|
|
387
|
-
<
|
|
1535
|
+
{#if actions.includes('edit')}
|
|
1536
|
+
<Button variant="ghost" type="button" class="quiet-action" onclick={() => onEdit(content)}>
|
|
1537
|
+
{t(M['content.content_list.edit'])}
|
|
1538
|
+
</Button>
|
|
388
1539
|
{/if}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
1540
|
+
{#if actions.includes('delete')}
|
|
1541
|
+
<Button
|
|
1542
|
+
variant="ghost"
|
|
1543
|
+
type="button"
|
|
1544
|
+
class="quiet-action quiet-action--danger"
|
|
1545
|
+
onclick={() => handleDeleteContent(row)}
|
|
1546
|
+
>
|
|
1547
|
+
{t(M['content.content_list.delete'])}
|
|
1548
|
+
</Button>
|
|
394
1549
|
{/if}
|
|
395
|
-
|
|
396
|
-
|
|
1550
|
+
</div>
|
|
1551
|
+
</article>
|
|
1552
|
+
{/each}
|
|
1553
|
+
</div>
|
|
1554
|
+
{:else}
|
|
1555
|
+
<div class="content-grid">
|
|
1556
|
+
{#each pageRows as row (row.id)}
|
|
1557
|
+
{@const content = row.content}
|
|
1558
|
+
{@const actions = rowActions(row)}
|
|
1559
|
+
<div class="content-card">
|
|
1560
|
+
{#if content.thumbnailAssetId}
|
|
1561
|
+
<div class="card-thumbnail">
|
|
1562
|
+
<ImageThumbnail
|
|
1563
|
+
apiBaseUrl={apiBaseUrl}
|
|
1564
|
+
assetId={content.thumbnailAssetId}
|
|
1565
|
+
/>
|
|
1566
|
+
</div>
|
|
1567
|
+
{/if}
|
|
1568
|
+
<div class="content-header">
|
|
1569
|
+
<div class="content-header__eyebrow">
|
|
1570
|
+
<Checkbox
|
|
1571
|
+
checked={isSelected(row)}
|
|
1572
|
+
disabled={!row.identified}
|
|
1573
|
+
aria-label={selectRowLabel(row)}
|
|
1574
|
+
title={row.identified
|
|
1575
|
+
? undefined
|
|
1576
|
+
: t(M['content.content_list.row_not_selectable'])}
|
|
1577
|
+
onchange={() => toggleRow(row)}
|
|
1578
|
+
/>
|
|
1579
|
+
<span class={`type-pill type-pill--${row.type}`}>{row.typeLabel}</span>
|
|
1580
|
+
{#if row.author}
|
|
1581
|
+
<div class="author">{row.author}</div>
|
|
1582
|
+
{/if}
|
|
1583
|
+
</div>
|
|
1584
|
+
<h3>{row.title}</h3>
|
|
1585
|
+
</div>
|
|
1586
|
+
|
|
1587
|
+
<div class="content-meta">
|
|
1588
|
+
<div>{row.typeLabel}</div>
|
|
1589
|
+
<div class="badges">
|
|
1590
|
+
<span class="badge status-{contentStatusVariant(row.status)}">{row.statusLabel}</span>
|
|
1591
|
+
<span class="badge state-{contentStateVariant(row.state)}">{row.stateLabel}</span>
|
|
1592
|
+
</div>
|
|
1593
|
+
</div>
|
|
1594
|
+
|
|
1595
|
+
<p class="content-description">{row.description}</p>
|
|
1596
|
+
|
|
1597
|
+
<div class="content-footer">
|
|
1598
|
+
<div class="meta-links">
|
|
1599
|
+
{#if content.url}
|
|
1600
|
+
<div class="source">Source: <a href={content.url} target="_blank" rel="noreferrer">{content.url}</a></div>
|
|
1601
|
+
{/if}
|
|
1602
|
+
{#if content.fileKey}
|
|
1603
|
+
<div class="file">File: {content.fileKey}</div>
|
|
1604
|
+
{/if}
|
|
1605
|
+
</div>
|
|
1606
|
+
|
|
1607
|
+
<div class="content-actions">
|
|
1608
|
+
{#if actions.includes('view')}
|
|
1609
|
+
<a href={viewHref(row)} class="view-btn">{t(M['content.content_list.view_article_button'])}</a>
|
|
1610
|
+
{/if}
|
|
1611
|
+
{#if actions.includes('edit')}
|
|
1612
|
+
<Button variant="ghost" type="button" class="content-action-btn" onclick={() => onEdit(content)}>
|
|
1613
|
+
{t(M['content.content_list.edit'])}
|
|
1614
|
+
</Button>
|
|
1615
|
+
{/if}
|
|
1616
|
+
{#if actions.includes('delete')}
|
|
1617
|
+
<Button variant="ghost" type="button" class="content-action-btn delete-btn" onclick={() => handleDeleteContent(row)}>
|
|
1618
|
+
{t(M['content.content_list.delete'])}
|
|
1619
|
+
</Button>
|
|
1620
|
+
{/if}
|
|
1621
|
+
</div>
|
|
397
1622
|
</div>
|
|
398
1623
|
</div>
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
1624
|
+
{/each}
|
|
1625
|
+
</div>
|
|
1626
|
+
{/if}
|
|
1627
|
+
|
|
1628
|
+
{#if showPagination}
|
|
1629
|
+
<div class="content-pagination">
|
|
1630
|
+
<Pagination
|
|
1631
|
+
currentPage={tableState.page}
|
|
1632
|
+
{totalPages}
|
|
1633
|
+
onPageChange={handlePageChange}
|
|
1634
|
+
aria-label={t(M['content.content_list.pagination'])}
|
|
1635
|
+
/>
|
|
1636
|
+
</div>
|
|
1637
|
+
{/if}
|
|
402
1638
|
{/if}
|
|
403
1639
|
|
|
404
1640
|
</div>
|
|
@@ -407,7 +1643,7 @@ function cancelDelete() {
|
|
|
407
1643
|
open={pendingDelete !== null}
|
|
408
1644
|
title={t(M['content.content_list.delete_confirm_title'])}
|
|
409
1645
|
message={t(M['content.content_list.delete_confirm_message'], {
|
|
410
|
-
title: pendingDelete ?
|
|
1646
|
+
title: pendingDelete ? pendingDelete.title : '',
|
|
411
1647
|
})}
|
|
412
1648
|
confirmLabel={t(M['content.content_list.delete'])}
|
|
413
1649
|
cancelLabel={t(M['content.content_list.cancel'])}
|
|
@@ -516,6 +1752,20 @@ function cancelDelete() {
|
|
|
516
1752
|
box-shadow: 0 4px 6px -1px color-mix(in srgb, var(--smrt-color-primary) 50%, transparent);
|
|
517
1753
|
}
|
|
518
1754
|
|
|
1755
|
+
/* Selection summary shared by every presentation. */
|
|
1756
|
+
.content-selection {
|
|
1757
|
+
display: flex;
|
|
1758
|
+
align-items: center;
|
|
1759
|
+
gap: 0.75rem;
|
|
1760
|
+
padding: 0.35rem 0.1rem 0.75rem;
|
|
1761
|
+
color: var(--smrt-color-on-surface-variant);
|
|
1762
|
+
font-size: var(--smrt-typography-body-medium-size, 0.875rem);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
.content-selection :global(input[type='checkbox']) {
|
|
1766
|
+
cursor: pointer;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
519
1769
|
.content-header__eyebrow,
|
|
520
1770
|
.content-row__eyebrow {
|
|
521
1771
|
display: flex;
|
|
@@ -658,7 +1908,7 @@ function cancelDelete() {
|
|
|
658
1908
|
color: var(--smrt-color-primary);
|
|
659
1909
|
text-decoration: none;
|
|
660
1910
|
}
|
|
661
|
-
|
|
1911
|
+
|
|
662
1912
|
.source a:hover {
|
|
663
1913
|
text-decoration: underline;
|
|
664
1914
|
}
|
|
@@ -723,13 +1973,17 @@ function cancelDelete() {
|
|
|
723
1973
|
|
|
724
1974
|
.content-row {
|
|
725
1975
|
display: grid;
|
|
726
|
-
grid-template-columns: minmax(0, 1.8fr) auto auto;
|
|
1976
|
+
grid-template-columns: auto minmax(0, 1.8fr) auto auto;
|
|
727
1977
|
gap: 1.25rem;
|
|
728
1978
|
align-items: start;
|
|
729
1979
|
padding: 1.1rem 0;
|
|
730
1980
|
border-bottom: 1px solid var(--smrt-color-outline-variant);
|
|
731
1981
|
}
|
|
732
1982
|
|
|
1983
|
+
.content-row__select {
|
|
1984
|
+
padding-top: 0.25rem;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
733
1987
|
.content-row h3 {
|
|
734
1988
|
margin: 0;
|
|
735
1989
|
font-size: var(--smrt-typography-title-medium-size, 1.1rem);
|
|
@@ -816,53 +2070,39 @@ function cancelDelete() {
|
|
|
816
2070
|
box-shadow: var(--smrt-elevation-1, 0 1px 3px rgba(0,0,0,0.05));
|
|
817
2071
|
}
|
|
818
2072
|
|
|
819
|
-
.content-
|
|
820
|
-
|
|
821
|
-
border-collapse: collapse;
|
|
822
|
-
text-align: left;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
.content-table th {
|
|
826
|
-
background: var(--smrt-color-surface-container-low);
|
|
827
|
-
padding: 1rem;
|
|
828
|
-
font-size: var(--smrt-typography-title-small-size, 0.875rem);
|
|
829
|
-
font-weight: var(--smrt-typography-weight-semibold, 600);
|
|
2073
|
+
.content-refreshing {
|
|
2074
|
+
margin: 0 0 0.75rem;
|
|
830
2075
|
color: var(--smrt-color-on-surface-variant);
|
|
831
|
-
border-bottom: 1px solid var(--smrt-color-outline-variant);
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
.content-table td {
|
|
835
|
-
padding: 1rem;
|
|
836
|
-
border-bottom: 1px solid var(--smrt-color-outline-variant);
|
|
837
|
-
color: var(--smrt-color-on-surface);
|
|
838
2076
|
font-size: var(--smrt-typography-body-medium-size, 0.875rem);
|
|
839
|
-
vertical-align: middle;
|
|
840
2077
|
}
|
|
841
2078
|
|
|
842
|
-
.content-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
.content-table tr:hover {
|
|
847
|
-
background: var(--smrt-color-surface-container-low);
|
|
2079
|
+
.content-pagination {
|
|
2080
|
+
display: flex;
|
|
2081
|
+
justify-content: center;
|
|
2082
|
+
margin-top: 1.25rem;
|
|
848
2083
|
}
|
|
849
2084
|
|
|
850
|
-
.
|
|
851
|
-
|
|
2085
|
+
.table-empty-state {
|
|
2086
|
+
margin: 0;
|
|
2087
|
+
padding: 1.5rem 0;
|
|
2088
|
+
text-align: center;
|
|
2089
|
+
color: var(--smrt-color-on-surface-variant);
|
|
852
2090
|
}
|
|
853
2091
|
|
|
854
|
-
.title-
|
|
855
|
-
color: var(--smrt-color-
|
|
2092
|
+
.title-link {
|
|
2093
|
+
color: var(--smrt-color-primary);
|
|
856
2094
|
font-weight: var(--smrt-typography-weight-semibold, 600);
|
|
2095
|
+
text-decoration: none;
|
|
857
2096
|
}
|
|
858
2097
|
|
|
859
|
-
.
|
|
860
|
-
|
|
861
|
-
text-align: right;
|
|
2098
|
+
.title-link:hover {
|
|
2099
|
+
text-decoration: underline;
|
|
862
2100
|
}
|
|
863
2101
|
|
|
864
2102
|
.actions-cell {
|
|
865
|
-
|
|
2103
|
+
display: flex;
|
|
2104
|
+
justify-content: flex-end;
|
|
2105
|
+
gap: 0.15rem;
|
|
866
2106
|
white-space: nowrap;
|
|
867
2107
|
}
|
|
868
2108
|
|
|
@@ -875,6 +2115,7 @@ function cancelDelete() {
|
|
|
875
2115
|
border-radius: 0.25rem;
|
|
876
2116
|
transition: background 0.2s;
|
|
877
2117
|
opacity: 0.7;
|
|
2118
|
+
text-decoration: none;
|
|
878
2119
|
}
|
|
879
2120
|
|
|
880
2121
|
.actions-cell :global(.icon-btn:hover) {
|
|
@@ -886,7 +2127,45 @@ function cancelDelete() {
|
|
|
886
2127
|
background: var(--smrt-color-error-container);
|
|
887
2128
|
}
|
|
888
2129
|
|
|
889
|
-
.
|
|
2130
|
+
/* Saved views live beside the search and filters they name. */
|
|
2131
|
+
.saved-views {
|
|
2132
|
+
display: flex;
|
|
2133
|
+
gap: 0.5rem;
|
|
2134
|
+
align-items: center;
|
|
2135
|
+
flex-wrap: wrap;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
/* Reports what a restored link, saved view, or server query discarded. */
|
|
2139
|
+
.state-notice {
|
|
2140
|
+
margin-bottom: 1rem;
|
|
2141
|
+
padding: 0.75rem 1rem;
|
|
2142
|
+
border-radius: 0.5rem;
|
|
2143
|
+
border: 1px solid var(--smrt-color-outline-variant);
|
|
2144
|
+
background: var(--smrt-color-surface-container-low);
|
|
2145
|
+
color: var(--smrt-color-on-surface-variant);
|
|
2146
|
+
font-size: var(--smrt-typography-body-medium-size, 0.875rem);
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
.state-notice__title {
|
|
2150
|
+
margin: 0 0 0.35rem;
|
|
2151
|
+
font-weight: var(--smrt-typography-weight-semibold, 600);
|
|
2152
|
+
color: var(--smrt-color-on-surface);
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
.state-notice__list {
|
|
2156
|
+
margin: 0 0 0.5rem;
|
|
2157
|
+
padding-left: 1.25rem;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
.state-notice :global(.state-notice__dismiss) {
|
|
2161
|
+
border: 1px solid var(--smrt-color-outline-variant);
|
|
2162
|
+
border-radius: 0.375rem;
|
|
2163
|
+
padding: 0.25rem 0.75rem;
|
|
2164
|
+
cursor: pointer;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
/* Shared empty, loading, and error presentation. */
|
|
2168
|
+
.state-panel {
|
|
890
2169
|
background: var(--smrt-color-surface);
|
|
891
2170
|
padding: 4rem;
|
|
892
2171
|
text-align: center;
|
|
@@ -896,13 +2175,33 @@ function cancelDelete() {
|
|
|
896
2175
|
font-size: var(--smrt-typography-body-large-size, 1.1rem);
|
|
897
2176
|
}
|
|
898
2177
|
|
|
899
|
-
.
|
|
2178
|
+
.state-panel--error {
|
|
2179
|
+
border-style: solid;
|
|
2180
|
+
border-color: var(--smrt-color-error);
|
|
2181
|
+
color: var(--smrt-color-on-surface);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
.state-panel__title {
|
|
2185
|
+
margin: 0 0 0.5rem;
|
|
2186
|
+
font-weight: var(--smrt-typography-weight-semibold, 600);
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
.state-panel__detail {
|
|
2190
|
+
margin: 0 0 1rem;
|
|
900
2191
|
color: var(--smrt-color-on-surface-variant);
|
|
2192
|
+
font-size: var(--smrt-typography-body-medium-size, 0.875rem);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
.state-panel :global(.retry-button) {
|
|
2196
|
+
border: 1px solid var(--smrt-color-outline);
|
|
2197
|
+
border-radius: 0.5rem;
|
|
2198
|
+
padding: 0.5rem 1rem;
|
|
2199
|
+
cursor: pointer;
|
|
901
2200
|
}
|
|
902
2201
|
|
|
903
2202
|
@media (max-width: 960px) {
|
|
904
2203
|
.content-row {
|
|
905
|
-
grid-template-columns: minmax(0, 1fr);
|
|
2204
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
906
2205
|
gap: 0.9rem;
|
|
907
2206
|
}
|
|
908
2207
|
|