@vit-foundation/ui 0.24.1 → 0.25.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 +11 -11
- package/dist/components/pages/GetInvolvedPage.svelte +139 -0
- package/dist/components/pages/GetInvolvedPage.svelte.d.ts +36 -0
- package/dist/components/pages/HomePage.svelte +256 -0
- package/dist/components/pages/HomePage.svelte.d.ts +36 -0
- package/dist/components/pages/LegalPage.svelte +39 -0
- package/dist/components/pages/LegalPage.svelte.d.ts +14 -0
- package/dist/components/pages/ProjectPage.svelte +128 -0
- package/dist/components/pages/ProjectPage.svelte.d.ts +17 -0
- package/dist/components/pages/TransparencyPage.svelte +164 -0
- package/dist/components/pages/TransparencyPage.svelte.d.ts +40 -0
- package/dist/components/pages/WeekliesPage.svelte +184 -0
- package/dist/components/pages/WeekliesPage.svelte.d.ts +46 -0
- package/dist/components/pages/WeeklyPage.svelte +203 -0
- package/dist/components/pages/WeeklyPage.svelte.d.ts +35 -0
- package/dist/components/pages/WhatWeDoPage.svelte +107 -0
- package/dist/components/pages/WhatWeDoPage.svelte.d.ts +27 -0
- package/dist/components/pages/WhoWeArePage.svelte +108 -0
- package/dist/components/pages/WhoWeArePage.svelte.d.ts +32 -0
- package/dist/components/ui/Logo.svelte +19 -3
- package/dist/config/messages.js +22 -0
- package/dist/config/types.d.ts +36 -0
- package/dist/content/index.d.ts +3 -1
- package/dist/content/index.js +1 -0
- package/dist/content/pages.d.ts +64 -0
- package/dist/content/pages.js +71 -0
- package/dist/content/types.d.ts +45 -0
- package/dist/content/types.js +0 -5
- package/dist/content-components.d.ts +18 -0
- package/dist/content-components.js +12 -0
- package/dist/contract.d.ts +3 -0
- package/dist/contract.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { ArticleEdit } from '../../content/types.js';
|
|
3
|
+
|
|
4
|
+
/** What a CMS may open on a project's own page: title, excerpt, and the body as rich text. */
|
|
5
|
+
export type ProjectPageEdit = ArticleEdit;
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import { getUiConfig } from '../../config/context.js';
|
|
10
|
+
import type { ProjectArticleData } from '../../content/types.js';
|
|
11
|
+
import ActionLabel from '../../edit/ActionLabel.svelte';
|
|
12
|
+
import Editable from '../../edit/Editable.svelte';
|
|
13
|
+
import PageShell from '../layout/PageShell.svelte';
|
|
14
|
+
import DateText from '../ui/DateText.svelte';
|
|
15
|
+
import Link from '../ui/Link.svelte';
|
|
16
|
+
import RichText from '../ui/RichText.svelte';
|
|
17
|
+
import ShareRow from '../ui/ShareRow.svelte';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A project's story: date, title, excerpt, hero image, the rich-text body
|
|
21
|
+
* with a share row, and — when the project points somewhere — a preview
|
|
22
|
+
* card of the external destination. Whether a project HAS a page is the
|
|
23
|
+
* host's load's decision (a body-less project 404s there).
|
|
24
|
+
*/
|
|
25
|
+
interface Props {
|
|
26
|
+
project: ProjectArticleData;
|
|
27
|
+
edit?: ProjectPageEdit;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let { project, edit }: Props = $props();
|
|
31
|
+
|
|
32
|
+
const config = getUiConfig();
|
|
33
|
+
const msg = $derived(config.messages);
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<PageShell title={project.title} description={project.excerpt} variant="article">
|
|
37
|
+
<p class="back">
|
|
38
|
+
<ActionLabel edit={config.messageEdit?.('back_label')} value={msg.back_label()}>
|
|
39
|
+
{#snippet control()}
|
|
40
|
+
<Link href="/what-we-do">← {msg.back_label()}</Link>
|
|
41
|
+
{/snippet}
|
|
42
|
+
</ActionLabel>
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
<header>
|
|
46
|
+
<DateText value={project.publishedOn} />
|
|
47
|
+
<Editable edit={edit?.title} value={project.title}>
|
|
48
|
+
{#snippet children(text, attrs)}<h1 {...attrs}>{text}</h1>{/snippet}
|
|
49
|
+
</Editable>
|
|
50
|
+
<Editable edit={edit?.excerpt} value={project.excerpt}>
|
|
51
|
+
{#snippet children(text, attrs)}<p class="excerpt" {...attrs}>{text}</p>{/snippet}
|
|
52
|
+
</Editable>
|
|
53
|
+
</header>
|
|
54
|
+
|
|
55
|
+
<img class="hero" src={project.imageUrl} alt="" width="1200" height="675" />
|
|
56
|
+
|
|
57
|
+
<div class="layout">
|
|
58
|
+
<div class="body">
|
|
59
|
+
{#if project.body}
|
|
60
|
+
<RichText body={project.body} edit={edit?.body} />
|
|
61
|
+
{/if}
|
|
62
|
+
<ShareRow title={project.title} />
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{#if project.externalUrl}
|
|
66
|
+
<aside>
|
|
67
|
+
<a class="preview" href={project.externalUrl} rel="external noopener">
|
|
68
|
+
{#if project.previewImageUrl}
|
|
69
|
+
<img src={project.previewImageUrl} alt="" width="900" height="300" />
|
|
70
|
+
{/if}
|
|
71
|
+
<span>{project.externalUrl.replace(/^https?:\/\//, '')} ↗</span>
|
|
72
|
+
</a>
|
|
73
|
+
</aside>
|
|
74
|
+
{/if}
|
|
75
|
+
</div>
|
|
76
|
+
</PageShell>
|
|
77
|
+
|
|
78
|
+
<style>
|
|
79
|
+
.back :global(a) {
|
|
80
|
+
text-decoration: none;
|
|
81
|
+
color: var(--color-ink-secondary);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
h1 {
|
|
85
|
+
margin: var(--space-2) 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.excerpt {
|
|
89
|
+
font-size: var(--text-lg);
|
|
90
|
+
color: var(--color-ink-secondary);
|
|
91
|
+
max-width: 60ch;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.hero {
|
|
95
|
+
border-radius: var(--radius-lg);
|
|
96
|
+
margin-block: var(--space-4);
|
|
97
|
+
background: var(--color-band-grey);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.layout {
|
|
101
|
+
display: grid;
|
|
102
|
+
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
|
103
|
+
gap: var(--space-5);
|
|
104
|
+
align-items: start;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.preview {
|
|
108
|
+
display: flex;
|
|
109
|
+
flex-direction: column;
|
|
110
|
+
gap: var(--space-2);
|
|
111
|
+
border: 1px solid var(--color-hairline);
|
|
112
|
+
border-radius: var(--radius);
|
|
113
|
+
padding: var(--space-3);
|
|
114
|
+
text-decoration: none;
|
|
115
|
+
color: var(--color-brand);
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.preview img {
|
|
120
|
+
border-radius: var(--radius);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@media (max-width: 720px) {
|
|
124
|
+
.layout {
|
|
125
|
+
grid-template-columns: 1fr;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ArticleEdit } from '../../content/types.js';
|
|
2
|
+
/** What a CMS may open on a project's own page: title, excerpt, and the body as rich text. */
|
|
3
|
+
export type ProjectPageEdit = ArticleEdit;
|
|
4
|
+
import type { ProjectArticleData } from '../../content/types.js';
|
|
5
|
+
/**
|
|
6
|
+
* A project's story: date, title, excerpt, hero image, the rich-text body
|
|
7
|
+
* with a share row, and — when the project points somewhere — a preview
|
|
8
|
+
* card of the external destination. Whether a project HAS a page is the
|
|
9
|
+
* host's load's decision (a body-less project 404s there).
|
|
10
|
+
*/
|
|
11
|
+
interface Props {
|
|
12
|
+
project: ProjectArticleData;
|
|
13
|
+
edit?: ProjectPageEdit;
|
|
14
|
+
}
|
|
15
|
+
declare const ProjectPage: import("svelte").Component<Props, {}, "">;
|
|
16
|
+
type ProjectPage = ReturnType<typeof ProjectPage>;
|
|
17
|
+
export default ProjectPage;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { CopyEditFor } from '../../content/pages.js';
|
|
3
|
+
import type { MilestoneData } from '../../content/types.js';
|
|
4
|
+
import type { CollectionRef } from '../../edit/types.js';
|
|
5
|
+
import type { MilestoneEditMap } from '../timeline/TimelineMilestone.svelte';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a CMS may open on /transparency. `milestones` names the timeline's
|
|
9
|
+
* collection (add slots, removes); the category chips edit through
|
|
10
|
+
* `config.messageEdit` over their own `category_*` keys, and the search
|
|
11
|
+
* placeholder through the box's panel — neither needs a member here.
|
|
12
|
+
*/
|
|
13
|
+
export interface TransparencyPageEdit {
|
|
14
|
+
copy?: CopyEditFor<'transparency'>;
|
|
15
|
+
milestoneFor?: (milestone: MilestoneData) => MilestoneEditMap | undefined;
|
|
16
|
+
milestones?: CollectionRef;
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
import { getUiConfig } from '../../config/context.js';
|
|
22
|
+
import type { PageCopy } from '../../content/pages.js';
|
|
23
|
+
import { MILESTONE_CATEGORIES, type MilestoneCategory } from '../../content/types.js';
|
|
24
|
+
import Editable from '../../edit/Editable.svelte';
|
|
25
|
+
import { chromeProperty } from '../../edit/helpers.js';
|
|
26
|
+
import { matchesMilestoneFilter, milestoneCategoryLabel } from '../../utils/milestones.js';
|
|
27
|
+
import { createUrlFilters } from '../../utils/url-filters.svelte.js';
|
|
28
|
+
import PageShell from '../layout/PageShell.svelte';
|
|
29
|
+
import Timeline from '../timeline/Timeline.svelte';
|
|
30
|
+
import CopyIntro from '../ui/CopyIntro.svelte';
|
|
31
|
+
import FilterChips from '../ui/FilterChips.svelte';
|
|
32
|
+
import SearchInput from '../ui/SearchInput.svelte';
|
|
33
|
+
|
|
34
|
+
/** The two filters, as they ride the URL. An alias: createUrlFilters needs an index signature. */
|
|
35
|
+
type TimelineFilters = { q: string; category: MilestoneCategory | null };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The full milestone history with a search box and category chips. The
|
|
39
|
+
* whole timeline is already loaded, so filtering stays in the browser
|
|
40
|
+
* (`matchesMilestoneFilter`); only the URL is mirrored, through the
|
|
41
|
+
* host's shallow-routing write — the package has no router.
|
|
42
|
+
*/
|
|
43
|
+
interface Props {
|
|
44
|
+
content: PageCopy<'transparency'>;
|
|
45
|
+
milestones: MilestoneData[];
|
|
46
|
+
/** The server-parsed filters of a deep link; invalid values already fell back. */
|
|
47
|
+
query: TimelineFilters;
|
|
48
|
+
/** Shallow-routing URL write — see UrlFiltersConfig.replaceUrl. */
|
|
49
|
+
replaceUrl: (path: string) => void;
|
|
50
|
+
edit?: TransparencyPageEdit;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let { content, milestones, query, replaceUrl, edit }: Props = $props();
|
|
54
|
+
|
|
55
|
+
const config = getUiConfig();
|
|
56
|
+
const msg = $derived(config.messages);
|
|
57
|
+
|
|
58
|
+
const filters = createUrlFilters<TimelineFilters>({
|
|
59
|
+
path: '/transparency',
|
|
60
|
+
initial: () => ({ q: query.q, category: query.category }),
|
|
61
|
+
toQuery: (values) => ({ q: values.q, category: values.category }),
|
|
62
|
+
replaceUrl: (path) => replaceUrl(path)
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// $derived, not a const: the labels resolve to STRINGS through `msg`, and
|
|
66
|
+
// a CMS switching its editing locale (or saving a wording) repaints every
|
|
67
|
+
// other string on the page — the chips must follow.
|
|
68
|
+
const chips = $derived(
|
|
69
|
+
MILESTONE_CATEGORIES.map((value) => ({ value, label: milestoneCategoryLabel(value, msg) }))
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const filtered = $derived(
|
|
73
|
+
milestones.filter((milestone) => matchesMilestoneFilter(milestone, filters.values))
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const isCategory = (value: string | null): value is MilestoneCategory =>
|
|
77
|
+
value !== null && (MILESTONE_CATEGORIES as readonly string[]).includes(value);
|
|
78
|
+
|
|
79
|
+
function handleSearch(value: string): void {
|
|
80
|
+
filters.update({ q: value });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function handleCategory(value: string | null): void {
|
|
84
|
+
filters.update({ category: isCategory(value) ? value : null });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A chip's wording is a catalog key — `category_lab` for `lab`. */
|
|
88
|
+
const categoryKey = (category: MilestoneCategory) => `category_${category}` as const;
|
|
89
|
+
|
|
90
|
+
const placeholderProperty = $derived(
|
|
91
|
+
config.messageEdit
|
|
92
|
+
? chromeProperty('timeline_searchPlaceholder', {
|
|
93
|
+
type: 'text',
|
|
94
|
+
locale: config.locale(),
|
|
95
|
+
label: msg.timeline_searchPlaceholder()
|
|
96
|
+
})
|
|
97
|
+
: undefined
|
|
98
|
+
);
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<PageShell title={content.heading} variant="wide">
|
|
102
|
+
<header>
|
|
103
|
+
<Editable edit={edit?.copy?.('heading')} value={content.heading}>
|
|
104
|
+
{#snippet children(text, attrs)}<h1 {...attrs}>{text}</h1>{/snippet}
|
|
105
|
+
</Editable>
|
|
106
|
+
<CopyIntro text={content.intro} edit={edit?.copy?.('intro')} />
|
|
107
|
+
</header>
|
|
108
|
+
|
|
109
|
+
<div class="controls">
|
|
110
|
+
<SearchInput
|
|
111
|
+
value={filters.values.q}
|
|
112
|
+
placeholder={msg.timeline_searchPlaceholder()}
|
|
113
|
+
label={msg.timeline_searchPlaceholder()}
|
|
114
|
+
onsearch={handleSearch}
|
|
115
|
+
placeholderEdit={placeholderProperty}
|
|
116
|
+
/>
|
|
117
|
+
<FilterChips
|
|
118
|
+
{chips}
|
|
119
|
+
selected={filters.values.category}
|
|
120
|
+
label={msg.timeline_filterLabel()}
|
|
121
|
+
onchange={handleCategory}
|
|
122
|
+
editFor={(chip) =>
|
|
123
|
+
isCategory(chip.value) ? config.messageEdit?.(categoryKey(chip.value)) : undefined}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<section aria-live="polite" aria-label={msg.timeline_label()}>
|
|
128
|
+
{#if filtered.length === 0}
|
|
129
|
+
<Editable edit={config.messageEdit?.('timeline_empty')} value={msg.timeline_empty()}>
|
|
130
|
+
{#snippet children(text, attrs)}<p class="empty" {...attrs}>{text}</p>{/snippet}
|
|
131
|
+
</Editable>
|
|
132
|
+
{:else}
|
|
133
|
+
<Timeline
|
|
134
|
+
milestones={filtered}
|
|
135
|
+
variant="full"
|
|
136
|
+
editFor={edit?.milestoneFor}
|
|
137
|
+
collection={edit?.milestones}
|
|
138
|
+
/>
|
|
139
|
+
{/if}
|
|
140
|
+
</section>
|
|
141
|
+
</PageShell>
|
|
142
|
+
|
|
143
|
+
<style>
|
|
144
|
+
header,
|
|
145
|
+
.controls {
|
|
146
|
+
max-width: var(--content-max);
|
|
147
|
+
margin-inline: auto;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.controls {
|
|
151
|
+
display: flex;
|
|
152
|
+
flex-wrap: wrap;
|
|
153
|
+
gap: var(--space-4);
|
|
154
|
+
align-items: center;
|
|
155
|
+
margin-block: var(--space-4);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.empty {
|
|
159
|
+
max-width: var(--content-max);
|
|
160
|
+
margin-inline: auto;
|
|
161
|
+
color: var(--color-ink-secondary);
|
|
162
|
+
padding-block: var(--space-5);
|
|
163
|
+
}
|
|
164
|
+
</style>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CopyEditFor } from '../../content/pages.js';
|
|
2
|
+
import type { MilestoneData } from '../../content/types.js';
|
|
3
|
+
import type { CollectionRef } from '../../edit/types.js';
|
|
4
|
+
import type { MilestoneEditMap } from '../timeline/TimelineMilestone.svelte';
|
|
5
|
+
/**
|
|
6
|
+
* What a CMS may open on /transparency. `milestones` names the timeline's
|
|
7
|
+
* collection (add slots, removes); the category chips edit through
|
|
8
|
+
* `config.messageEdit` over their own `category_*` keys, and the search
|
|
9
|
+
* placeholder through the box's panel — neither needs a member here.
|
|
10
|
+
*/
|
|
11
|
+
export interface TransparencyPageEdit {
|
|
12
|
+
copy?: CopyEditFor<'transparency'>;
|
|
13
|
+
milestoneFor?: (milestone: MilestoneData) => MilestoneEditMap | undefined;
|
|
14
|
+
milestones?: CollectionRef;
|
|
15
|
+
}
|
|
16
|
+
import type { PageCopy } from '../../content/pages.js';
|
|
17
|
+
import { type MilestoneCategory } from '../../content/types.js';
|
|
18
|
+
/** The two filters, as they ride the URL. An alias: createUrlFilters needs an index signature. */
|
|
19
|
+
type TimelineFilters = {
|
|
20
|
+
q: string;
|
|
21
|
+
category: MilestoneCategory | null;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The full milestone history with a search box and category chips. The
|
|
25
|
+
* whole timeline is already loaded, so filtering stays in the browser
|
|
26
|
+
* (`matchesMilestoneFilter`); only the URL is mirrored, through the
|
|
27
|
+
* host's shallow-routing write — the package has no router.
|
|
28
|
+
*/
|
|
29
|
+
interface Props {
|
|
30
|
+
content: PageCopy<'transparency'>;
|
|
31
|
+
milestones: MilestoneData[];
|
|
32
|
+
/** The server-parsed filters of a deep link; invalid values already fell back. */
|
|
33
|
+
query: TimelineFilters;
|
|
34
|
+
/** Shallow-routing URL write — see UrlFiltersConfig.replaceUrl. */
|
|
35
|
+
replaceUrl: (path: string) => void;
|
|
36
|
+
edit?: TransparencyPageEdit;
|
|
37
|
+
}
|
|
38
|
+
declare const TransparencyPage: import("svelte").Component<Props, {}, "">;
|
|
39
|
+
type TransparencyPage = ReturnType<typeof TransparencyPage>;
|
|
40
|
+
export default TransparencyPage;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { CopyEditFor } from '../../content/pages.js';
|
|
3
|
+
import type { ThemeData, WeeklyCardData } from '../../content/types.js';
|
|
4
|
+
import type { EditDescriptor } from '../../edit/types.js';
|
|
5
|
+
import type { WeeklyEditMap } from '../weeklies/WeeklieCard.svelte';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a CMS may open on /weeklies. A theme chip's wording is a THEME's
|
|
9
|
+
* localized name — an entity, unlike the category chips — so the host
|
|
10
|
+
* answers `themeFor` per theme. The search placeholder and the sort
|
|
11
|
+
* options edit through their panels over `config.messageEdit`; the h1
|
|
12
|
+
* and the empty state inline over the same gate.
|
|
13
|
+
*/
|
|
14
|
+
export interface WeekliesPageEdit {
|
|
15
|
+
copy?: CopyEditFor<'weeklies'>;
|
|
16
|
+
weeklyFor?: (weekly: WeeklyCardData) => WeeklyEditMap | undefined;
|
|
17
|
+
themeFor?: (theme: ThemeData) => EditDescriptor | undefined;
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<script lang="ts">
|
|
22
|
+
import { getUiConfig } from '../../config/context.js';
|
|
23
|
+
import type { PageCopy } from '../../content/pages.js';
|
|
24
|
+
import type { SortDirection } from '../../content/types.js';
|
|
25
|
+
import Editable from '../../edit/Editable.svelte';
|
|
26
|
+
import { chromeProperty } from '../../edit/helpers.js';
|
|
27
|
+
import type { WeeklyListServerData } from '../../utils/weekly-list-contract.js';
|
|
28
|
+
import { createWeeklyList, type WeeklyListConfig } from '../../utils/weekly-list.svelte.js';
|
|
29
|
+
import PageShell from '../layout/PageShell.svelte';
|
|
30
|
+
import CopyIntro from '../ui/CopyIntro.svelte';
|
|
31
|
+
import FilterChips from '../ui/FilterChips.svelte';
|
|
32
|
+
import Pagination from '../ui/Pagination.svelte';
|
|
33
|
+
import SearchInput from '../ui/SearchInput.svelte';
|
|
34
|
+
import SortSelect from '../weeklies/SortSelect.svelte';
|
|
35
|
+
import WeeklieCard from '../weeklies/WeeklieCard.svelte';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The weeklies index: theme chips, search, sort, the grid, paging.
|
|
39
|
+
* Filters, paging, the refetch and the server/client reconciliation are
|
|
40
|
+
* `createWeeklyList`'s; this renders what it reports. The three things
|
|
41
|
+
* the package deliberately lacks — a backend, an i18n runtime and a
|
|
42
|
+
* router — are the host's `fetchPage`, the provider's locale, and
|
|
43
|
+
* `replaceUrl`.
|
|
44
|
+
*
|
|
45
|
+
* The paging links go to `Pagination` UNWRAPPED: it renders them through
|
|
46
|
+
* `Link`, which resolves every internal path through `UiConfig.href`, so
|
|
47
|
+
* a host that re-roots the site (vit-brain's `mirrorHref`) must NOT wrap
|
|
48
|
+
* `hrefFor` itself — that prefixed the path twice.
|
|
49
|
+
*/
|
|
50
|
+
interface Props {
|
|
51
|
+
content: PageCopy<'weeklies'>;
|
|
52
|
+
themes: ThemeData[];
|
|
53
|
+
/** The server-rendered page — the website's route `data` satisfies it as is. */
|
|
54
|
+
server: WeeklyListServerData;
|
|
55
|
+
/** The host's remote query for the first page of a changed filter. */
|
|
56
|
+
fetchPage: WeeklyListConfig['fetchPage'];
|
|
57
|
+
/** Shallow-routing URL write — see UrlFiltersConfig.replaceUrl. */
|
|
58
|
+
replaceUrl: (path: string) => void;
|
|
59
|
+
edit?: WeekliesPageEdit;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let { content, themes, server, fetchPage, replaceUrl, edit }: Props = $props();
|
|
63
|
+
|
|
64
|
+
const config = getUiConfig();
|
|
65
|
+
const msg = $derived(config.messages);
|
|
66
|
+
|
|
67
|
+
const list = createWeeklyList({
|
|
68
|
+
server: () => server,
|
|
69
|
+
fetchPage: (input) => fetchPage(input),
|
|
70
|
+
locale: () => config.locale(),
|
|
71
|
+
replaceUrl: (path) => replaceUrl(path)
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const chips = $derived(themes.map((theme) => ({ value: theme.slug, label: theme.name })));
|
|
75
|
+
const themeEdit = (chip: { value: string }) => {
|
|
76
|
+
const theme = themes.find((candidate) => candidate.slug === chip.value);
|
|
77
|
+
return theme ? edit?.themeFor?.(theme) : undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Wording that cannot hold a caret — the placeholder, the two <option>
|
|
81
|
+
// labels — edits through a panel, only where the host exposes `messageEdit`.
|
|
82
|
+
const placeholderProperty = $derived(
|
|
83
|
+
config.messageEdit
|
|
84
|
+
? chromeProperty('weeklies_searchPlaceholder', {
|
|
85
|
+
type: 'text',
|
|
86
|
+
locale: config.locale(),
|
|
87
|
+
label: msg.weeklies_searchPlaceholder()
|
|
88
|
+
})
|
|
89
|
+
: undefined
|
|
90
|
+
);
|
|
91
|
+
const sortOptionProperty = (direction: SortDirection) =>
|
|
92
|
+
config.messageEdit
|
|
93
|
+
? chromeProperty(direction === 'desc' ? 'weeklies_sortDesc' : 'weeklies_sortAsc', {
|
|
94
|
+
type: 'text',
|
|
95
|
+
locale: config.locale(),
|
|
96
|
+
label: direction === 'desc' ? msg.weeklies_sortDesc() : msg.weeklies_sortAsc()
|
|
97
|
+
})
|
|
98
|
+
: undefined;
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<PageShell title={msg.nav_weeklies()}>
|
|
102
|
+
<header>
|
|
103
|
+
<Editable edit={config.messageEdit?.('nav_weeklies')} value={msg.nav_weeklies()}>
|
|
104
|
+
{#snippet children(text, attrs)}<h1 {...attrs}>{text}</h1>{/snippet}
|
|
105
|
+
</Editable>
|
|
106
|
+
<CopyIntro text={content.intro} edit={edit?.copy?.('intro')} />
|
|
107
|
+
</header>
|
|
108
|
+
|
|
109
|
+
<div class="controls">
|
|
110
|
+
<FilterChips
|
|
111
|
+
{chips}
|
|
112
|
+
selected={list.filters.theme}
|
|
113
|
+
label={msg.weeklies_filterLabel()}
|
|
114
|
+
onchange={(value) => list.update({ theme: value })}
|
|
115
|
+
editFor={themeEdit}
|
|
116
|
+
/>
|
|
117
|
+
<SearchInput
|
|
118
|
+
value={list.filters.q}
|
|
119
|
+
placeholder={msg.weeklies_searchPlaceholder()}
|
|
120
|
+
label={msg.weeklies_searchPlaceholder()}
|
|
121
|
+
onsearch={(q) => list.update({ q })}
|
|
122
|
+
placeholderEdit={placeholderProperty}
|
|
123
|
+
/>
|
|
124
|
+
<SortSelect
|
|
125
|
+
value={list.filters.sort}
|
|
126
|
+
onchange={(sort) => list.update({ sort })}
|
|
127
|
+
optionsEdit={sortOptionProperty}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<section
|
|
132
|
+
aria-live="polite"
|
|
133
|
+
aria-busy={list.isLoading}
|
|
134
|
+
aria-label={msg.weeklies_searchResultsLabel()}
|
|
135
|
+
>
|
|
136
|
+
{#if list.loadError}
|
|
137
|
+
<p class="load-error" role="alert">{msg.weeklies_loadError()}</p>
|
|
138
|
+
{/if}
|
|
139
|
+
{#if list.items.length === 0}
|
|
140
|
+
<Editable edit={config.messageEdit?.('weeklies_empty')} value={msg.weeklies_empty()}>
|
|
141
|
+
{#snippet children(text, attrs)}<p class="empty" {...attrs}>{text}</p>{/snippet}
|
|
142
|
+
</Editable>
|
|
143
|
+
{:else}
|
|
144
|
+
<div class="grid">
|
|
145
|
+
{#each list.items as weekly (weekly.slug)}
|
|
146
|
+
<WeeklieCard {weekly} edit={edit?.weeklyFor?.(weekly)} />
|
|
147
|
+
{/each}
|
|
148
|
+
</div>
|
|
149
|
+
{/if}
|
|
150
|
+
</section>
|
|
151
|
+
|
|
152
|
+
<Pagination
|
|
153
|
+
page={list.page}
|
|
154
|
+
total={list.total}
|
|
155
|
+
pageSize={server.pageSize}
|
|
156
|
+
href={(page) => list.hrefFor(page)}
|
|
157
|
+
/>
|
|
158
|
+
</PageShell>
|
|
159
|
+
|
|
160
|
+
<style>
|
|
161
|
+
.controls {
|
|
162
|
+
display: flex;
|
|
163
|
+
flex-wrap: wrap;
|
|
164
|
+
align-items: center;
|
|
165
|
+
gap: var(--space-4);
|
|
166
|
+
margin-block: var(--space-4);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.grid {
|
|
170
|
+
display: grid;
|
|
171
|
+
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
|
172
|
+
gap: var(--space-5) var(--space-4);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.empty {
|
|
176
|
+
color: var(--color-ink-secondary);
|
|
177
|
+
padding-block: var(--space-5);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.load-error {
|
|
181
|
+
color: var(--series-8);
|
|
182
|
+
font-weight: 600;
|
|
183
|
+
}
|
|
184
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { CopyEditFor } from '../../content/pages.js';
|
|
2
|
+
import type { ThemeData, WeeklyCardData } from '../../content/types.js';
|
|
3
|
+
import type { EditDescriptor } from '../../edit/types.js';
|
|
4
|
+
import type { WeeklyEditMap } from '../weeklies/WeeklieCard.svelte';
|
|
5
|
+
/**
|
|
6
|
+
* What a CMS may open on /weeklies. A theme chip's wording is a THEME's
|
|
7
|
+
* localized name — an entity, unlike the category chips — so the host
|
|
8
|
+
* answers `themeFor` per theme. The search placeholder and the sort
|
|
9
|
+
* options edit through their panels over `config.messageEdit`; the h1
|
|
10
|
+
* and the empty state inline over the same gate.
|
|
11
|
+
*/
|
|
12
|
+
export interface WeekliesPageEdit {
|
|
13
|
+
copy?: CopyEditFor<'weeklies'>;
|
|
14
|
+
weeklyFor?: (weekly: WeeklyCardData) => WeeklyEditMap | undefined;
|
|
15
|
+
themeFor?: (theme: ThemeData) => EditDescriptor | undefined;
|
|
16
|
+
}
|
|
17
|
+
import type { PageCopy } from '../../content/pages.js';
|
|
18
|
+
import type { WeeklyListServerData } from '../../utils/weekly-list-contract.js';
|
|
19
|
+
import { type WeeklyListConfig } from '../../utils/weekly-list.svelte.js';
|
|
20
|
+
/**
|
|
21
|
+
* The weeklies index: theme chips, search, sort, the grid, paging.
|
|
22
|
+
* Filters, paging, the refetch and the server/client reconciliation are
|
|
23
|
+
* `createWeeklyList`'s; this renders what it reports. The three things
|
|
24
|
+
* the package deliberately lacks — a backend, an i18n runtime and a
|
|
25
|
+
* router — are the host's `fetchPage`, the provider's locale, and
|
|
26
|
+
* `replaceUrl`.
|
|
27
|
+
*
|
|
28
|
+
* The paging links go to `Pagination` UNWRAPPED: it renders them through
|
|
29
|
+
* `Link`, which resolves every internal path through `UiConfig.href`, so
|
|
30
|
+
* a host that re-roots the site (vit-brain's `mirrorHref`) must NOT wrap
|
|
31
|
+
* `hrefFor` itself — that prefixed the path twice.
|
|
32
|
+
*/
|
|
33
|
+
interface Props {
|
|
34
|
+
content: PageCopy<'weeklies'>;
|
|
35
|
+
themes: ThemeData[];
|
|
36
|
+
/** The server-rendered page — the website's route `data` satisfies it as is. */
|
|
37
|
+
server: WeeklyListServerData;
|
|
38
|
+
/** The host's remote query for the first page of a changed filter. */
|
|
39
|
+
fetchPage: WeeklyListConfig['fetchPage'];
|
|
40
|
+
/** Shallow-routing URL write — see UrlFiltersConfig.replaceUrl. */
|
|
41
|
+
replaceUrl: (path: string) => void;
|
|
42
|
+
edit?: WeekliesPageEdit;
|
|
43
|
+
}
|
|
44
|
+
declare const WeekliesPage: import("svelte").Component<Props, {}, "">;
|
|
45
|
+
type WeekliesPage = ReturnType<typeof WeekliesPage>;
|
|
46
|
+
export default WeekliesPage;
|