@vit-foundation/ui 0.15.0 → 0.16.0
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/README.md +10 -10
- package/dist/components/jobs/JobList.svelte +11 -19
- package/dist/components/jobs/JobList.svelte.d.ts +1 -1
- package/dist/components/layout/Footer.svelte +9 -15
- package/dist/components/layout/Nav.svelte +10 -15
- package/dist/components/projects/ProjectCard.svelte +2 -2
- package/dist/components/projects/ProjectCard.svelte.d.ts +1 -1
- package/dist/components/team/CollaboratorList.svelte +10 -16
- package/dist/components/timeline/Timeline.svelte +10 -23
- package/dist/components/timeline/TimelineMilestone.svelte +2 -2
- package/dist/components/timeline/TimelineMilestone.svelte.d.ts +1 -1
- package/dist/components/weeklies/WeeklieCard.svelte +2 -2
- package/dist/components/weeklies/WeeklieCard.svelte.d.ts +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/messages.js +5 -1
- package/dist/config/types.d.ts +35 -3
- package/dist/content-components.d.ts +5 -1
- package/dist/content-components.js +4 -1
- package/dist/edit/chrome/EditPanel.svelte +2 -2
- package/dist/edit/chrome/EditPanel.svelte.d.ts +2 -2
- package/dist/edit/chrome/PropertyRow.svelte +40 -9
- package/dist/edit/chrome/PropertyRow.svelte.d.ts +6 -2
- package/dist/edit/collection.svelte.d.ts +49 -0
- package/dist/edit/collection.svelte.js +39 -0
- package/dist/edit/helpers.d.ts +5 -5
- package/dist/edit/helpers.js +2 -2
- package/dist/edit/index.d.ts +4 -1
- package/dist/edit/index.js +1 -0
- package/dist/edit/types.d.ts +24 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/utils/milestones.d.ts +15 -0
- package/dist/utils/milestones.js +17 -0
- package/dist/utils/paths.d.ts +6 -0
- package/dist/utils/paths.js +14 -0
- package/dist/utils/url-filters.svelte.d.ts +58 -0
- package/dist/utils/url-filters.svelte.js +47 -0
- package/dist/utils/weekly-list.svelte.d.ts +82 -0
- package/dist/utils/weekly-list.svelte.js +120 -0
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { getUiConfig } from '../../config/context.js';
|
|
3
|
+
import type { ParameterlessKey } from '../../config/types.js';
|
|
3
4
|
import { getEditAdapter } from '../context.js';
|
|
4
|
-
import type { PropertyDescriptor } from '../types.js';
|
|
5
|
+
import type { PropertyDescriptor, PropertyValue } from '../types.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* One property of an EditPanel: label, a control by `descriptor.type`, and
|
|
@@ -11,10 +12,14 @@
|
|
|
11
12
|
*
|
|
12
13
|
* The row assumes an adapter with `saveProperty` exists — EditPanel only
|
|
13
14
|
* renders when the frame's triple gate already established both.
|
|
15
|
+
*
|
|
16
|
+
* The control state is text throughout — a flag's boolean rides as
|
|
17
|
+
* 'true'/'false' in its <select> and becomes a boolean again only at the
|
|
18
|
+
* adapter boundary, so one draft/saved pair serves every type.
|
|
14
19
|
*/
|
|
15
20
|
interface Props {
|
|
16
21
|
descriptor: PropertyDescriptor;
|
|
17
|
-
value:
|
|
22
|
+
value: PropertyValue;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
let { descriptor, value }: Props = $props();
|
|
@@ -22,21 +27,28 @@
|
|
|
22
27
|
const adapter = getEditAdapter();
|
|
23
28
|
const config = getUiConfig();
|
|
24
29
|
|
|
30
|
+
/** The control's reading of a property value. */
|
|
31
|
+
const asText = (next: PropertyValue): string =>
|
|
32
|
+
typeof next === 'boolean' ? String(next) : (next ?? '');
|
|
33
|
+
|
|
34
|
+
/** A flag state's wording, from the same catalog the site renders. */
|
|
35
|
+
const wording = (key: ParameterlessKey): string => config.messages[key]?.() ?? key;
|
|
36
|
+
|
|
25
37
|
let status = $state<'idle' | 'dirty' | 'saving' | 'error'>('idle');
|
|
26
38
|
let announcement = $state('');
|
|
27
39
|
/** Last persisted value, what a failed draft is measured against. */
|
|
28
40
|
// svelte-ignore state_referenced_locally
|
|
29
|
-
let savedValue = $state(value
|
|
41
|
+
let savedValue = $state(asText(value));
|
|
30
42
|
// svelte-ignore state_referenced_locally
|
|
31
|
-
let draft = $state(value
|
|
43
|
+
let draft = $state(asText(value));
|
|
32
44
|
let fileInput: HTMLInputElement | undefined = $state();
|
|
33
45
|
|
|
34
46
|
// Follow the prop only while idle — never repaint a held draft (the
|
|
35
47
|
// Editable rule).
|
|
36
48
|
// svelte-ignore state_referenced_locally
|
|
37
|
-
let lastPropValue = $state(value
|
|
49
|
+
let lastPropValue = $state(asText(value));
|
|
38
50
|
$effect(() => {
|
|
39
|
-
const next = value
|
|
51
|
+
const next = asText(value);
|
|
40
52
|
if (next !== lastPropValue) {
|
|
41
53
|
lastPropValue = next;
|
|
42
54
|
if (status === 'idle') {
|
|
@@ -46,14 +58,14 @@
|
|
|
46
58
|
}
|
|
47
59
|
});
|
|
48
60
|
|
|
49
|
-
async function persist(next:
|
|
61
|
+
async function persist(next: PropertyValue): Promise<void> {
|
|
50
62
|
if (!adapter?.saveProperty) return;
|
|
51
63
|
status = 'saving';
|
|
52
64
|
announcement = config.editMessages.edit_saving();
|
|
53
65
|
try {
|
|
54
66
|
await adapter.saveProperty(descriptor, next);
|
|
55
|
-
savedValue = next
|
|
56
|
-
draft = next
|
|
67
|
+
savedValue = asText(next);
|
|
68
|
+
draft = asText(next);
|
|
57
69
|
status = 'idle';
|
|
58
70
|
announcement = config.editMessages.edit_saved();
|
|
59
71
|
} catch {
|
|
@@ -69,6 +81,10 @@
|
|
|
69
81
|
status = 'idle';
|
|
70
82
|
return;
|
|
71
83
|
}
|
|
84
|
+
if (descriptor.type === 'flag') {
|
|
85
|
+
void persist(trimmed === 'true');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
72
88
|
if (trimmed === '') {
|
|
73
89
|
if (!descriptor.nullable) {
|
|
74
90
|
status = 'error';
|
|
@@ -122,6 +138,21 @@
|
|
|
122
138
|
<option value={option.value}>{option.label}</option>
|
|
123
139
|
{/each}
|
|
124
140
|
</select>
|
|
141
|
+
{:else if descriptor.type === 'flag'}
|
|
142
|
+
<!-- Two states, worded from the catalog: the host names the pair
|
|
143
|
+
(published/draft, open/closed), the adapter receives a boolean. -->
|
|
144
|
+
<select
|
|
145
|
+
{id}
|
|
146
|
+
data-vit-editing={status}
|
|
147
|
+
value={draft}
|
|
148
|
+
onchange={(event) => {
|
|
149
|
+
draft = event.currentTarget.value;
|
|
150
|
+
commit();
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
<option value="true">{wording(descriptor.on ?? 'status_published')}</option>
|
|
154
|
+
<option value="false">{wording(descriptor.off ?? 'status_draft')}</option>
|
|
155
|
+
</select>
|
|
125
156
|
{:else if descriptor.type === 'image'}
|
|
126
157
|
{#if savedValue}
|
|
127
158
|
<img class="thumb" src={savedValue} alt="" />
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PropertyDescriptor } from '../types.js';
|
|
1
|
+
import type { PropertyDescriptor, PropertyValue } from '../types.js';
|
|
2
2
|
/**
|
|
3
3
|
* One property of an EditPanel: label, a control by `descriptor.type`, and
|
|
4
4
|
* its own commit lifecycle — Editable's state machine, panel-shaped. Each
|
|
@@ -7,10 +7,14 @@ import type { PropertyDescriptor } from '../types.js';
|
|
|
7
7
|
*
|
|
8
8
|
* The row assumes an adapter with `saveProperty` exists — EditPanel only
|
|
9
9
|
* renders when the frame's triple gate already established both.
|
|
10
|
+
*
|
|
11
|
+
* The control state is text throughout — a flag's boolean rides as
|
|
12
|
+
* 'true'/'false' in its <select> and becomes a boolean again only at the
|
|
13
|
+
* adapter boundary, so one draft/saved pair serves every type.
|
|
10
14
|
*/
|
|
11
15
|
interface Props {
|
|
12
16
|
descriptor: PropertyDescriptor;
|
|
13
|
-
value:
|
|
17
|
+
value: PropertyValue;
|
|
14
18
|
}
|
|
15
19
|
declare const PropertyRow: import("svelte").Component<Props, {}, "">;
|
|
16
20
|
type PropertyRow = ReturnType<typeof PropertyRow>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CollectionRef, EntityOp } from './types.js';
|
|
2
|
+
type CreateOp = Extract<EntityOp, {
|
|
3
|
+
kind: 'create';
|
|
4
|
+
}>;
|
|
5
|
+
type RemoveOp = Extract<EntityOp, {
|
|
6
|
+
kind: 'remove';
|
|
7
|
+
}>;
|
|
8
|
+
/** An edit map a list may inject removal into. */
|
|
9
|
+
export interface RemovableMap {
|
|
10
|
+
/** Set by the list from its `collection` — removal of this row. */
|
|
11
|
+
removeOp?: RemoveOp;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The structural half of one editable LIST, as its template reads it.
|
|
15
|
+
* Both members answer `undefined` while the collection is not live, so the
|
|
16
|
+
* template's `{#if}` is the whole gate.
|
|
17
|
+
*/
|
|
18
|
+
export interface CollectionEditing<Row, Map extends RemovableMap> {
|
|
19
|
+
/** The create op for the add slot at the end of the list. */
|
|
20
|
+
readonly add: CreateOp | undefined;
|
|
21
|
+
/** The create op for an add slot anchored before one row (Timeline). */
|
|
22
|
+
addBefore(id: string | number): CreateOp | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* The host's map for one row, plus the remove op the LIST injects — lists
|
|
25
|
+
* own identity, so a host never spells a removal. Rows without an `id`
|
|
26
|
+
* keep the host's map untouched: a remove op needs an identity, and
|
|
27
|
+
* read-only hosts never pass one.
|
|
28
|
+
*/
|
|
29
|
+
mapFor(row: Row): Map | undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* What every list with a `collection` prop used to repeat — Timeline,
|
|
33
|
+
* CollaboratorList, JobList, Nav and Footer each carried the triple gate
|
|
34
|
+
* (collection named ∧ adapter editing ∧ `applyOp` present) and the same
|
|
35
|
+
* remove-op injection, five times verbatim. One helper, one place to keep
|
|
36
|
+
* the rule: a structural affordance renders only when all three hold.
|
|
37
|
+
*
|
|
38
|
+
* `read` is a thunk over the list's props so both stay reactive; the
|
|
39
|
+
* adapter comes from context, so call this during component init like
|
|
40
|
+
* `getEditAdapter` itself.
|
|
41
|
+
*/
|
|
42
|
+
export declare function collectionEditing<Row extends {
|
|
43
|
+
id?: string | number;
|
|
44
|
+
}, Map extends RemovableMap>(read: () => {
|
|
45
|
+
collection: CollectionRef | undefined;
|
|
46
|
+
/** The host's per-row map (`editFor`, or Nav/Footer's `propertiesFor`). */
|
|
47
|
+
editFor: ((row: Row) => Map | undefined) | undefined;
|
|
48
|
+
}): CollectionEditing<Row, Map>;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getEditAdapter } from './context.js';
|
|
2
|
+
/**
|
|
3
|
+
* What every list with a `collection` prop used to repeat — Timeline,
|
|
4
|
+
* CollaboratorList, JobList, Nav and Footer each carried the triple gate
|
|
5
|
+
* (collection named ∧ adapter editing ∧ `applyOp` present) and the same
|
|
6
|
+
* remove-op injection, five times verbatim. One helper, one place to keep
|
|
7
|
+
* the rule: a structural affordance renders only when all three hold.
|
|
8
|
+
*
|
|
9
|
+
* `read` is a thunk over the list's props so both stay reactive; the
|
|
10
|
+
* adapter comes from context, so call this during component init like
|
|
11
|
+
* `getEditAdapter` itself.
|
|
12
|
+
*/
|
|
13
|
+
export function collectionEditing(read) {
|
|
14
|
+
const adapter = getEditAdapter();
|
|
15
|
+
const live = $derived.by(() => {
|
|
16
|
+
const { collection } = read();
|
|
17
|
+
return collection !== undefined &&
|
|
18
|
+
(adapter?.isEditing ?? false) &&
|
|
19
|
+
adapter?.applyOp !== undefined
|
|
20
|
+
? collection
|
|
21
|
+
: undefined;
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
get add() {
|
|
25
|
+
return live ? { kind: 'create', collection: live } : undefined;
|
|
26
|
+
},
|
|
27
|
+
addBefore(id) {
|
|
28
|
+
return live
|
|
29
|
+
? { kind: 'create', collection: live, anchor: { id, placement: 'before' } }
|
|
30
|
+
: undefined;
|
|
31
|
+
},
|
|
32
|
+
mapFor(row) {
|
|
33
|
+
const map = read().editFor?.(row);
|
|
34
|
+
if (!live || row.id === undefined)
|
|
35
|
+
return map;
|
|
36
|
+
return { ...map, removeOp: { kind: 'remove', collection: live, id: row.id } };
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
package/dist/edit/helpers.d.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import type { Locale } from '../config/types.js';
|
|
1
|
+
import type { Locale, NotParameterized, ParameterlessKey } from '../config/types.js';
|
|
2
2
|
import type { CollectionRef, EditableEntity, EditDescriptor, PropertyDescriptor } from './types.js';
|
|
3
3
|
/** Everything a property descriptor carries beyond its ref. */
|
|
4
4
|
type PropertySpec = Omit<PropertyDescriptor, 'ref'>;
|
|
5
5
|
/**
|
|
6
6
|
* Descriptor for one interface-wording message (a Paraglide catalog key).
|
|
7
|
-
*
|
|
8
|
-
* RENDERED text of a parameterized one would overwrite its template.
|
|
7
|
+
* The key type carries the rule — see `ParameterlessKey` in ../config/types.js.
|
|
9
8
|
*/
|
|
10
|
-
export declare function chromeEdit(key:
|
|
9
|
+
export declare function chromeEdit(key: ParameterlessKey, locale: Locale, options?: {
|
|
11
10
|
format?: EditDescriptor['format'];
|
|
12
11
|
label?: string;
|
|
13
12
|
}): EditDescriptor;
|
|
@@ -38,8 +37,9 @@ export declare function entityProperty(entity: EditableEntity, id: string | numb
|
|
|
38
37
|
/**
|
|
39
38
|
* A panel property over one interface-wording message — how strings that can
|
|
40
39
|
* never hold a caret (an `<option>` label, an input placeholder) still edit.
|
|
40
|
+
* Accepts the site's own keys too; see `NotParameterized` in ../config/types.js.
|
|
41
41
|
*/
|
|
42
|
-
export declare function chromeProperty(key:
|
|
42
|
+
export declare function chromeProperty<K extends string>(key: NotParameterized<K>, spec: PropertySpec): PropertyDescriptor;
|
|
43
43
|
/** Names one entity collection at one render site. */
|
|
44
44
|
export declare function collectionOf(entity: EditableEntity, scope?: string): CollectionRef;
|
|
45
45
|
export {};
|
package/dist/edit/helpers.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Descriptor for one interface-wording message (a Paraglide catalog key).
|
|
3
|
-
*
|
|
4
|
-
* RENDERED text of a parameterized one would overwrite its template.
|
|
3
|
+
* The key type carries the rule — see `ParameterlessKey` in ../config/types.js.
|
|
5
4
|
*/
|
|
6
5
|
export function chromeEdit(key, locale, options) {
|
|
7
6
|
return { ref: { kind: 'chrome', key }, locale, ...options };
|
|
@@ -33,6 +32,7 @@ export function entityProperty(entity, id) {
|
|
|
33
32
|
/**
|
|
34
33
|
* A panel property over one interface-wording message — how strings that can
|
|
35
34
|
* never hold a caret (an `<option>` label, an input placeholder) still edit.
|
|
35
|
+
* Accepts the site's own keys too; see `NotParameterized` in ../config/types.js.
|
|
36
36
|
*/
|
|
37
37
|
export function chromeProperty(key, spec) {
|
|
38
38
|
return { ref: { kind: 'chrome', key }, ...spec };
|
package/dist/edit/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { getEditAdapter, setEditAdapter } from './context.js';
|
|
2
|
+
export { collectionEditing } from './collection.svelte.js';
|
|
3
|
+
export type { CollectionEditing, RemovableMap } from './collection.svelte.js';
|
|
2
4
|
export { default as ActionLabel } from './ActionLabel.svelte';
|
|
3
5
|
export { default as Editable } from './Editable.svelte';
|
|
4
6
|
export { default as AddSlot } from './chrome/AddSlot.svelte';
|
|
@@ -10,4 +12,5 @@ export { default as EditPopover } from './chrome/EditPopover.svelte';
|
|
|
10
12
|
export { default as LinkEdit } from './chrome/LinkEdit.svelte';
|
|
11
13
|
export { chromeEdit, chromeProperty, collectionOf, entityEdit, entityProperty, pageCopyEdit } from './helpers.js';
|
|
12
14
|
export { localize } from './types.js';
|
|
13
|
-
export type { CollectionRef, ContentRef, EditableEntity, EditAdapter, EditDescriptor, EntityOp, LocalizedText, PropertyDescriptor, PropertyOption, PropertyType } from './types.js';
|
|
15
|
+
export type { CollectionRef, ContentRef, EditableEntity, EditAdapter, EditDescriptor, EntityOp, LocalizedText, PropertyDescriptor, PropertyOption, PropertyType, PropertyValue } from './types.js';
|
|
16
|
+
export type { NotParameterized, ParameterlessKey } from '../config/types.js';
|
package/dist/edit/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { getEditAdapter, setEditAdapter } from './context.js';
|
|
2
|
+
export { collectionEditing } from './collection.svelte.js';
|
|
2
3
|
export { default as ActionLabel } from './ActionLabel.svelte';
|
|
3
4
|
export { default as Editable } from './Editable.svelte';
|
|
4
5
|
export { default as AddSlot } from './chrome/AddSlot.svelte';
|
package/dist/edit/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Locale } from '../config/types.js';
|
|
1
|
+
import type { Locale, ParameterlessKey } from '../config/types.js';
|
|
2
2
|
/**
|
|
3
3
|
* The stored shape of one localized text column (`jsonb` keyed by locale).
|
|
4
4
|
* Catalan is canonical and required — the database enforces `content ? 'ca'`
|
|
@@ -52,12 +52,19 @@ export interface EditDescriptor {
|
|
|
52
52
|
label?: string;
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
|
-
* The value kinds a property panel can edit.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
55
|
+
* The value kinds a property panel can edit. All but one serialize to a
|
|
56
|
+
* string on the wire — an ISO date, a URL, an enum member, a storage path —
|
|
57
|
+
* so the ADAPTER parses and the host's schemas stay the sole validators. The
|
|
58
|
+
* exception is `flag`: a two-state row whose value IS a boolean (published or
|
|
59
|
+
* not, open or not), so no host has to spell a 'true'/'false' select and no
|
|
60
|
+
* card has to derive one from its data.
|
|
59
61
|
*/
|
|
60
|
-
export type PropertyType = 'text' | 'url' | 'date' | 'select' | 'image';
|
|
62
|
+
export type PropertyType = 'text' | 'url' | 'date' | 'select' | 'image' | 'flag';
|
|
63
|
+
/**
|
|
64
|
+
* What a property row hands the adapter: a string for every type but `flag`,
|
|
65
|
+
* a boolean for a flag, `null` when a `nullable` property is cleared.
|
|
66
|
+
*/
|
|
67
|
+
export type PropertyValue = string | boolean | null;
|
|
61
68
|
/** One choice of a `select` property. */
|
|
62
69
|
export interface PropertyOption {
|
|
63
70
|
value: string;
|
|
@@ -77,6 +84,13 @@ export interface PropertyDescriptor {
|
|
|
77
84
|
label: string;
|
|
78
85
|
/** Required when `type` is 'select'. */
|
|
79
86
|
options?: readonly PropertyOption[];
|
|
87
|
+
/**
|
|
88
|
+
* 'flag' only: the catalog keys wording each state, so the same row reads
|
|
89
|
+
* «Publicat / Esborrany» on a card and «Oberta / Tancada» on an opening.
|
|
90
|
+
* Default `status_published` / `status_draft`.
|
|
91
|
+
*/
|
|
92
|
+
on?: ParameterlessKey;
|
|
93
|
+
off?: ParameterlessKey;
|
|
80
94
|
placeholder?: string;
|
|
81
95
|
/** Clearing the field saves null (linkUrl, externalUrl). */
|
|
82
96
|
nullable?: boolean;
|
|
@@ -135,10 +149,11 @@ export interface EditAdapter {
|
|
|
135
149
|
readonly isEditing: boolean;
|
|
136
150
|
save(descriptor: EditDescriptor, value: string): Promise<void>;
|
|
137
151
|
/**
|
|
138
|
-
* Panel property save.
|
|
139
|
-
* url, enum member, image path)
|
|
152
|
+
* Panel property save. A string on the wire for every type but `flag`
|
|
153
|
+
* (ISO date, url, enum member, image path), a boolean for a flag; `null`
|
|
154
|
+
* clears a `nullable` property.
|
|
140
155
|
*/
|
|
141
|
-
saveProperty?(descriptor: PropertyDescriptor, value:
|
|
156
|
+
saveProperty?(descriptor: PropertyDescriptor, value: PropertyValue): Promise<void>;
|
|
142
157
|
/**
|
|
143
158
|
* Structural collection ops. `create` may resolve the new row's id so the
|
|
144
159
|
* UI can point at it after the host's refresh.
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -12,5 +12,5 @@ export * from './forms/index.js';
|
|
|
12
12
|
// App wiring: configuration context and the edit-mode contract.
|
|
13
13
|
export * from './config/index.js';
|
|
14
14
|
export * from './edit/index.js';
|
|
15
|
-
// Path
|
|
16
|
-
export { isPathUnder } from './utils/paths.js';
|
|
15
|
+
// Path utilities shared with host apps.
|
|
16
|
+
export { buildQueryString, isPathUnder } from './utils/paths.js';
|
|
@@ -7,5 +7,20 @@ import type { MilestoneCategory } from '../content/types.js';
|
|
|
7
7
|
* tokens.css.
|
|
8
8
|
*/
|
|
9
9
|
export declare const MILESTONE_CATEGORY_COLOR: Record<MilestoneCategory, string>;
|
|
10
|
+
/**
|
|
11
|
+
* The transparency page's client-side filter: the timeline is small enough
|
|
12
|
+
* to filter in the browser (a choice its load function records), and this is
|
|
13
|
+
* the predicate — named and tested here rather than closing over a page's
|
|
14
|
+
* `data` inside a `$derived`. Category is exact; the query is a trimmed,
|
|
15
|
+
* case-insensitive substring of title + body.
|
|
16
|
+
*/
|
|
17
|
+
export declare function matchesMilestoneFilter(milestone: {
|
|
18
|
+
title: string;
|
|
19
|
+
body: string | null;
|
|
20
|
+
category: MilestoneCategory;
|
|
21
|
+
}, filter: {
|
|
22
|
+
q: string;
|
|
23
|
+
category: MilestoneCategory | null;
|
|
24
|
+
}): boolean;
|
|
10
25
|
/** The category's label in the host app's copy. */
|
|
11
26
|
export declare function milestoneCategoryLabel(category: MilestoneCategory, messages: UiMessages): string;
|
package/dist/utils/milestones.js
CHANGED
|
@@ -11,6 +11,23 @@ export const MILESTONE_CATEGORY_COLOR = {
|
|
|
11
11
|
collaboration: 'var(--series-4)',
|
|
12
12
|
press: 'var(--series-5)'
|
|
13
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* The transparency page's client-side filter: the timeline is small enough
|
|
16
|
+
* to filter in the browser (a choice its load function records), and this is
|
|
17
|
+
* the predicate — named and tested here rather than closing over a page's
|
|
18
|
+
* `data` inside a `$derived`. Category is exact; the query is a trimmed,
|
|
19
|
+
* case-insensitive substring of title + body.
|
|
20
|
+
*/
|
|
21
|
+
export function matchesMilestoneFilter(milestone, filter) {
|
|
22
|
+
if (filter.category && milestone.category !== filter.category)
|
|
23
|
+
return false;
|
|
24
|
+
if (!filter.q)
|
|
25
|
+
return true;
|
|
26
|
+
const needle = filter.q.trim().toLowerCase();
|
|
27
|
+
if (!needle)
|
|
28
|
+
return true;
|
|
29
|
+
return `${milestone.title} ${milestone.body ?? ''}`.toLowerCase().includes(needle);
|
|
30
|
+
}
|
|
14
31
|
/** The category's label in the host app's copy. */
|
|
15
32
|
export function milestoneCategoryLabel(category, messages) {
|
|
16
33
|
switch (category) {
|
package/dist/utils/paths.d.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
/** Prefix match over a URL pathname: '/what-we-do' covers '/what-we-do/<slug>'. */
|
|
2
2
|
export declare function isPathUnder(pathname: string, prefix: string): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Builds "?a=1&b=2" from the truthy entries, or "" when none remain — the one
|
|
5
|
+
* way a filter or a page number joins a canonical path, so the URL a list
|
|
6
|
+
* mirrors and the hrefs it renders are spelled by the same function.
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildQueryString(params: Record<string, string | null | undefined>): string;
|
package/dist/utils/paths.js
CHANGED
|
@@ -2,3 +2,17 @@
|
|
|
2
2
|
export function isPathUnder(pathname, prefix) {
|
|
3
3
|
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* Builds "?a=1&b=2" from the truthy entries, or "" when none remain — the one
|
|
7
|
+
* way a filter or a page number joins a canonical path, so the URL a list
|
|
8
|
+
* mirrors and the hrefs it renders are spelled by the same function.
|
|
9
|
+
*/
|
|
10
|
+
export function buildQueryString(params) {
|
|
11
|
+
const search = new URLSearchParams();
|
|
12
|
+
for (const [key, value] of Object.entries(params)) {
|
|
13
|
+
if (value)
|
|
14
|
+
search.set(key, value);
|
|
15
|
+
}
|
|
16
|
+
const encoded = search.toString();
|
|
17
|
+
return encoded ? `?${encoded}` : '';
|
|
18
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export interface UrlFiltersConfig<T extends Record<string, unknown>> {
|
|
2
|
+
/** Unlocalized path the query string is appended to. */
|
|
3
|
+
path: string;
|
|
4
|
+
/**
|
|
5
|
+
* The server-rendered values. A thunk so callers reference props in a
|
|
6
|
+
* closure; read inside an effect, so it tracks them and re-seeds when a
|
|
7
|
+
* navigation changes what the server sent.
|
|
8
|
+
*/
|
|
9
|
+
initial: () => T;
|
|
10
|
+
/** Which values ride the URL. Null/undefined/empty entries are dropped. */
|
|
11
|
+
toQuery: (values: T) => Record<string, string | null | undefined>;
|
|
12
|
+
/** Runs after the URL is mirrored; omit for purely client-side filtering. */
|
|
13
|
+
onChange?: () => void;
|
|
14
|
+
/**
|
|
15
|
+
* How a mirrored URL is written. Required, not defaulted: shallow routing
|
|
16
|
+
* is the host's router's (SvelteKit's `replaceState` through its locale
|
|
17
|
+
* prefixing), and the package has no router — the same reason hrefs
|
|
18
|
+
* resolve through `UiConfig.href`.
|
|
19
|
+
*/
|
|
20
|
+
replaceUrl: (path: string) => void;
|
|
21
|
+
}
|
|
22
|
+
export interface UrlFilters<T extends Record<string, unknown>> {
|
|
23
|
+
/** Current filter values, for the controls to render. */
|
|
24
|
+
readonly values: T;
|
|
25
|
+
/**
|
|
26
|
+
* The params riding the URL for the current values. Exposed so a caller
|
|
27
|
+
* can build a URL that carries the filters plus something of its own —
|
|
28
|
+
* the weeklies index adds a page number. What that extra means, and when
|
|
29
|
+
* it survives a filter change, is the caller's rule.
|
|
30
|
+
*/
|
|
31
|
+
readonly query: Record<string, string | null | undefined>;
|
|
32
|
+
/** Applies a change, mirrors it into the URL, then notifies. */
|
|
33
|
+
update(patch: Partial<T>): void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Filter state that lives in the URL, in both directions: seeded from the
|
|
37
|
+
* values the server rendered, mirrored back on every client change so the
|
|
38
|
+
* page stays deep-linkable, and re-seeded whenever the server sends
|
|
39
|
+
* different ones.
|
|
40
|
+
*
|
|
41
|
+
* The site's /weeklies and /transparency each wrote this by hand — the
|
|
42
|
+
* seeding, the mirroring, and a handful of `state_referenced_locally`
|
|
43
|
+
* suppressions apiece. `initial` is a thunk, so a page reads its
|
|
44
|
+
* server-rendered data inside a closure, which is what the compiler asked for.
|
|
45
|
+
*
|
|
46
|
+
* The client→URL direction alone was not enough. Read once, `initial` left a
|
|
47
|
+
* same-route navigation carrying different filters — the header's own
|
|
48
|
+
* "Weeklies" link, followed from /weeklies?theme=salut — with the controls
|
|
49
|
+
* displaying the previous filter while the page below showed the server's
|
|
50
|
+
* unfiltered answer. Which surface went stale differed per caller: one
|
|
51
|
+
* contradicted its own chips, the other kept filtering by a chip the URL no
|
|
52
|
+
* longer carried. One rule, two symptoms, so it lives here and not in either.
|
|
53
|
+
*
|
|
54
|
+
* What a page does *with* a change stays the page's — refetch through a
|
|
55
|
+
* remote function, or filter a list it already has — which is why this owns
|
|
56
|
+
* the URL and not the filtering.
|
|
57
|
+
*/
|
|
58
|
+
export declare function createUrlFilters<T extends Record<string, unknown>>(config: UrlFiltersConfig<T>): UrlFilters<T>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { buildQueryString } from './paths.js';
|
|
2
|
+
/**
|
|
3
|
+
* Filter state that lives in the URL, in both directions: seeded from the
|
|
4
|
+
* values the server rendered, mirrored back on every client change so the
|
|
5
|
+
* page stays deep-linkable, and re-seeded whenever the server sends
|
|
6
|
+
* different ones.
|
|
7
|
+
*
|
|
8
|
+
* The site's /weeklies and /transparency each wrote this by hand — the
|
|
9
|
+
* seeding, the mirroring, and a handful of `state_referenced_locally`
|
|
10
|
+
* suppressions apiece. `initial` is a thunk, so a page reads its
|
|
11
|
+
* server-rendered data inside a closure, which is what the compiler asked for.
|
|
12
|
+
*
|
|
13
|
+
* The client→URL direction alone was not enough. Read once, `initial` left a
|
|
14
|
+
* same-route navigation carrying different filters — the header's own
|
|
15
|
+
* "Weeklies" link, followed from /weeklies?theme=salut — with the controls
|
|
16
|
+
* displaying the previous filter while the page below showed the server's
|
|
17
|
+
* unfiltered answer. Which surface went stale differed per caller: one
|
|
18
|
+
* contradicted its own chips, the other kept filtering by a chip the URL no
|
|
19
|
+
* longer carried. One rule, two symptoms, so it lives here and not in either.
|
|
20
|
+
*
|
|
21
|
+
* What a page does *with* a change stays the page's — refetch through a
|
|
22
|
+
* remote function, or filter a list it already has — which is why this owns
|
|
23
|
+
* the URL and not the filtering.
|
|
24
|
+
*/
|
|
25
|
+
export function createUrlFilters(config) {
|
|
26
|
+
const values = $state({ ...config.initial() });
|
|
27
|
+
// A client change mirrors through replaceState, which does not re-run the
|
|
28
|
+
// load — so `initial()` still reports what the server last sent and this
|
|
29
|
+
// does not fight the reader's own filtering. It fires on a real
|
|
30
|
+
// navigation, exactly when the controls would otherwise keep old filters.
|
|
31
|
+
$effect(() => {
|
|
32
|
+
Object.assign(values, config.initial());
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
get values() {
|
|
36
|
+
return values;
|
|
37
|
+
},
|
|
38
|
+
get query() {
|
|
39
|
+
return config.toQuery(values);
|
|
40
|
+
},
|
|
41
|
+
update(patch) {
|
|
42
|
+
Object.assign(values, patch);
|
|
43
|
+
config.replaceUrl(`${config.path}${buildQueryString(config.toQuery(values))}`);
|
|
44
|
+
config.onChange?.();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Locale } from '../config/types.js';
|
|
2
|
+
import type { SortDirection, WeeklyCardData } from '../content/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The weeklies index's URL defaults: the value a param stands for when it is
|
|
5
|
+
* absent. Both halves of the URL contract — this module omitting a default,
|
|
6
|
+
* the host's query schema supplying one — must name the same values, so a
|
|
7
|
+
* host derives its schema defaults from here rather than restating them.
|
|
8
|
+
*/
|
|
9
|
+
export declare const WEEKLY_LIST_DEFAULTS: {
|
|
10
|
+
readonly sort: "desc";
|
|
11
|
+
readonly page: 1;
|
|
12
|
+
};
|
|
13
|
+
/** A type alias, not an interface: createUrlFilters needs an index signature. */
|
|
14
|
+
export type WeeklyListFilters = {
|
|
15
|
+
q: string;
|
|
16
|
+
theme: string | null;
|
|
17
|
+
sort: SortDirection;
|
|
18
|
+
};
|
|
19
|
+
/** One page of the list plus the unpaged total (for the pagination). */
|
|
20
|
+
export interface WeeklyListPage {
|
|
21
|
+
items: WeeklyCardData[];
|
|
22
|
+
total: number;
|
|
23
|
+
}
|
|
24
|
+
/** The server-rendered page, re-read on every navigation. */
|
|
25
|
+
export interface WeeklyListServerData {
|
|
26
|
+
weeklies: WeeklyCardData[];
|
|
27
|
+
total: number;
|
|
28
|
+
page: number;
|
|
29
|
+
pageSize: number;
|
|
30
|
+
query: WeeklyListFilters;
|
|
31
|
+
}
|
|
32
|
+
export interface WeeklyListConfig {
|
|
33
|
+
/** Reads the route's `data`; called inside an effect, so it tracks it. */
|
|
34
|
+
server: () => WeeklyListServerData;
|
|
35
|
+
/**
|
|
36
|
+
* Fetches the first page of a filter — the host's remote query. Required,
|
|
37
|
+
* like `locale` and `replaceUrl`: the package never talks to a backend, an
|
|
38
|
+
* i18n runtime or a router, so the site hands in its own three.
|
|
39
|
+
*/
|
|
40
|
+
fetchPage: (input: {
|
|
41
|
+
q?: string;
|
|
42
|
+
theme?: string | null;
|
|
43
|
+
sort: SortDirection;
|
|
44
|
+
limit: number;
|
|
45
|
+
locale: Locale;
|
|
46
|
+
}) => Promise<WeeklyListPage>;
|
|
47
|
+
/** The locale being rendered. Reactive read. */
|
|
48
|
+
locale: () => Locale;
|
|
49
|
+
/** Shallow-routing URL write — see UrlFiltersConfig.replaceUrl. */
|
|
50
|
+
replaceUrl: (path: string) => void;
|
|
51
|
+
}
|
|
52
|
+
export interface WeeklyList {
|
|
53
|
+
readonly items: WeeklyCardData[];
|
|
54
|
+
readonly total: number;
|
|
55
|
+
/** A client-side refetch always shows the first page of the new filter. */
|
|
56
|
+
readonly page: number;
|
|
57
|
+
readonly isLoading: boolean;
|
|
58
|
+
readonly loadError: boolean;
|
|
59
|
+
/** Current filter values, for the controls to render. */
|
|
60
|
+
readonly filters: WeeklyListFilters;
|
|
61
|
+
/** Href for a 1-based page, carrying the active filters. */
|
|
62
|
+
hrefFor(page: number): string;
|
|
63
|
+
/** Applies a filter change: mirrors the URL, then refetches page one. */
|
|
64
|
+
update(patch: Partial<WeeklyListFilters>): void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The weeklies index view: the filters that ride the URL, the page the
|
|
68
|
+
* server rendered, and the page a filter change refetched in place.
|
|
69
|
+
*
|
|
70
|
+
* The two disagree by design — a changed filter returns to the first page
|
|
71
|
+
* while the URL still carries the old one — and reconciling them was four
|
|
72
|
+
* `$state` cells, an `$effect` and three `$derived` sitting in a route file,
|
|
73
|
+
* where nothing could reach them. Two things went wrong there: `loadError`
|
|
74
|
+
* was never cleared on navigation, so a failed refetch kept its alert on
|
|
75
|
+
* screen above fresh results; and a failed refetch left the URL, the grid
|
|
76
|
+
* and the pagination status giving three different answers.
|
|
77
|
+
*
|
|
78
|
+
* `createUrlFilters` owns the URL and deliberately not the fetching, because
|
|
79
|
+
* the transparency page filters client-side over data it already has. This
|
|
80
|
+
* owns the fetching and the reconciliation, for the one page that does both.
|
|
81
|
+
*/
|
|
82
|
+
export declare function createWeeklyList(config: WeeklyListConfig): WeeklyList;
|