@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.
Files changed (34) hide show
  1. package/AGENTS.md +9 -0
  2. package/agents/content-list.md +869 -0
  3. package/dist/content-query.d.ts +310 -0
  4. package/dist/content-query.d.ts.map +1 -0
  5. package/dist/contents.d.ts +22 -0
  6. package/dist/contents.d.ts.map +1 -1
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +672 -4
  10. package/dist/index.js.map +1 -1
  11. package/dist/manifest.json +22 -2
  12. package/dist/smrt-knowledge.json +38 -5
  13. package/dist/svelte/components/ContentList.svelte +1580 -281
  14. package/dist/svelte/components/ContentList.svelte.d.ts +54 -2
  15. package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
  16. package/dist/svelte/content-list-controller.d.ts +306 -0
  17. package/dist/svelte/content-list-controller.d.ts.map +1 -0
  18. package/dist/svelte/content-list-controller.js +921 -0
  19. package/dist/svelte/content-list-query.d.ts +498 -0
  20. package/dist/svelte/content-list-query.d.ts.map +1 -0
  21. package/dist/svelte/content-list-query.js +1294 -0
  22. package/dist/svelte/content-list-saved-views.d.ts +172 -0
  23. package/dist/svelte/content-list-saved-views.d.ts.map +1 -0
  24. package/dist/svelte/content-list-saved-views.js +298 -0
  25. package/dist/svelte/content-list-url-state.d.ts +211 -0
  26. package/dist/svelte/content-list-url-state.d.ts.map +1 -0
  27. package/dist/svelte/content-list-url-state.js +856 -0
  28. package/dist/svelte/i18n.contribution.d.ts +55 -0
  29. package/dist/svelte/i18n.contribution.d.ts.map +1 -1
  30. package/dist/svelte/i18n.contribution.js +57 -0
  31. package/dist/svelte/index.d.ts +5 -0
  32. package/dist/svelte/index.d.ts.map +1 -1
  33. package/dist/svelte/index.js +10 -0
  34. 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
- contents: ContentData[];
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?: 'grid' | 'detailed' | 'compact';
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
- let searchTerm = $state('');
39
- let selectedType = $state('All Types');
40
- let selectedStatus = $state('All Statuses');
41
- let viewMode: 'grid' | 'detailed' | 'compact' = $state(
42
- untrack(() => defaultViewMode),
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
- $effect(() => {
46
- selectedType = type || 'All Types';
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
- function getTextValue(value: unknown): string {
50
- return typeof value === 'string' ? value : '';
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
- function getDisplayTitle(content: ContentData): string {
54
- return getTextValue(content.title) || 'Untitled content';
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
- function getDisplayDescription(content: ContentData): string {
58
- return getTextValue(content.description);
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
- function getDisplayAuthor(content: ContentData): string {
62
- return getTextValue(content.author);
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
- function getNormalizedType(value: unknown): string {
66
- return getTextValue(value).toLowerCase() || 'content';
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 filteredContents = $derived(
70
- contents.filter((content: ContentData) => {
71
- const title = getDisplayTitle(content);
72
- const description = getDisplayDescription(content);
73
- const author = getDisplayAuthor(content);
74
-
75
- const matchesSearch =
76
- searchTerm === '' ||
77
- title.toLowerCase().includes(searchTerm.toLowerCase()) ||
78
- description.toLowerCase().includes(searchTerm.toLowerCase()) ||
79
- author.toLowerCase().includes(searchTerm.toLowerCase());
80
-
81
- const isLockedType = !!type;
82
- const matchesType = isLockedType
83
- ? content.type === type
84
- : selectedType === 'All Types' ||
85
- (selectedType === 'Articles' && content.type === 'article') ||
86
- (selectedType === 'Documents' && content.type === 'document') ||
87
- (selectedType === 'Mirrors' && content.type === 'mirror');
88
-
89
- const matchesStatus =
90
- selectedStatus === 'All Statuses' ||
91
- content.status.toLowerCase() === selectedStatus.toLowerCase();
92
-
93
- return matchesSearch && matchesType && matchesStatus;
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
- function getTypeLabel(value: unknown) {
98
- switch (getNormalizedType(value)) {
99
- case 'article':
100
- return 'Article';
101
- case 'mirror':
102
- return 'Mirror';
103
- case 'document':
104
- return 'Document';
105
- default:
106
- return 'Content';
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 getStatusBadge(value: unknown) {
111
- switch (getTextValue(value).toLowerCase()) {
112
- case 'published':
113
- return 'published';
114
- case 'draft':
115
- return 'draft';
116
- case 'archived':
117
- return 'archived';
118
- default:
119
- return 'unknown';
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 getStateBadge(value: unknown) {
124
- switch (getTextValue(value).toLowerCase()) {
125
- case 'highlighted':
126
- return 'highlighted';
127
- case 'active':
128
- return 'active';
129
- case 'deprecated':
130
- return 'deprecated';
131
- default:
132
- return 'unknown';
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
- let pendingDelete = $state<ContentData | null>(null);
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 handleDeleteContent(content: ContentData) {
139
- pendingDelete = content;
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 type="text" placeholder={t(M['content.content_list.search_placeholder'])} bind:value={searchTerm} />
160
-
161
- {#if !type}
162
- <Select bind:value={selectedType}>
163
- <option value="All Types">{t(M['content.content_list.all_types'])}</option>
164
- <option value="Articles">Articles</option>
165
- <option value="Documents">Documents</option>
166
- <option value="Mirrors">Mirrors</option>
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 bind:value={selectedStatus}>
171
- <option value="All Statuses">{t(M['content.content_list.all_statuses'])}</option>
172
- <option value="Published">Published</option>
173
- <option value="Draft">Draft</option>
174
- <option value="Archived">Archived</option>
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 filteredContents.length === 0}
249
- <div class="empty-state">
250
- {t(M['content.content_list.empty'])}
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
- {:else if viewMode === 'compact'}
253
- <div class="content-table-wrapper">
254
- <table class="content-table">
255
- <thead>
256
- <tr>
257
- <th>Type</th>
258
- <th>Title</th>
259
- <th>Author</th>
260
- <th>Status</th>
261
- <th>State</th>
262
- <th class="actions-col">Actions</th>
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 if viewMode === 'detailed'}
290
- <div class="content-detailed">
291
- {#each filteredContents as content (content.id)}
292
- <article class="content-row">
293
- <div class="content-row__main">
294
- <div class="content-row__eyebrow">
295
- <span class={`type-pill type-pill--${getNormalizedType(content.type)}`}>
296
- {getTypeLabel(content.type)}
297
- </span>
298
- {#if getDisplayAuthor(content)}
299
- <span class="content-row__author">By {getDisplayAuthor(content)}</span>
300
- {/if}
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
- <h3>{getDisplayTitle(content)}</h3>
304
-
305
- {#if getDisplayDescription(content)}
306
- <p class="content-row__description">{getDisplayDescription(content)}</p>
307
- {/if}
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
- <div class="content-row__meta">
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
- <div class="content-row__actions">
331
- {#if getViewHref?.(content)}
332
- <a href={getViewHref(content) || '#'} class="quiet-action">{t(M['content.content_list.view_article'])}</a>
333
- {/if}
334
- <Button variant="ghost" type="button" class="quiet-action" onclick={() => onEdit(content)}>{t(M['content.content_list.edit'])}</Button>
335
- <Button
336
- variant="ghost"
337
- type="button"
338
- class="quiet-action quiet-action--danger"
339
- onclick={() => handleDeleteContent(content)}
340
- >
341
- {t(M['content.content_list.delete'])}
342
- </Button>
343
- </div>
344
- </article>
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
- <h3>{getDisplayTitle(content)}</h3>
369
- </div>
370
-
371
- <div class="content-meta">
372
- <div>{getTypeLabel(content.type)}</div>
373
- <div class="badges">
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
- </div>
378
-
379
- <p class="content-description">{getDisplayDescription(content)}</p>
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 content.fileKey}
387
- <div class="file">File: {content.fileKey}</div>
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
- </div>
390
-
391
- <div class="content-actions">
392
- {#if getViewHref?.(content)}
393
- <a href={getViewHref(content) || '#'} class="view-btn">{t(M['content.content_list.view_article_button'])}</a>
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
- <Button variant="ghost" type="button" class="content-action-btn" onclick={() => onEdit(content)}>{t(M['content.content_list.edit'])}</Button>
396
- <Button variant="ghost" type="button" class="content-action-btn delete-btn" onclick={() => handleDeleteContent(content)}>{t(M['content.content_list.delete'])}</Button>
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
- </div>
400
- {/each}
401
- </div>
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 ? getDisplayTitle(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-table {
820
- width: 100%;
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-table tr:last-child td {
843
- border-bottom: none;
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
- .type-cell {
851
- white-space: nowrap;
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-cell strong {
855
- color: var(--smrt-color-on-surface);
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
- .actions-col {
860
- width: 100px;
861
- text-align: right;
2098
+ .title-link:hover {
2099
+ text-decoration: underline;
862
2100
  }
863
2101
 
864
2102
  .actions-cell {
865
- text-align: right;
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
- .empty-state {
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
- .source, .file {
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