@vit-foundation/ui 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/account/AccountPanel.svelte +224 -0
- package/dist/components/account/AccountPanel.svelte.d.ts +37 -0
- package/dist/components/account/NewsletterSignup.svelte +91 -0
- package/dist/components/account/NewsletterSignup.svelte.d.ts +13 -0
- package/dist/components/account/index.d.ts +3 -0
- package/dist/components/account/index.js +2 -0
- package/dist/components/auth/AuthPageShell.svelte +108 -0
- package/dist/components/auth/AuthPageShell.svelte.d.ts +27 -0
- package/dist/components/auth/GoogleAuthForm.svelte +72 -0
- package/dist/components/auth/GoogleAuthForm.svelte.d.ts +26 -0
- package/dist/components/auth/LoginForm.svelte +176 -0
- package/dist/components/auth/LoginForm.svelte.d.ts +37 -0
- package/dist/components/auth/SignupForm.svelte +157 -0
- package/dist/components/auth/SignupForm.svelte.d.ts +27 -0
- package/dist/components/auth/index.d.ts +7 -0
- package/dist/components/auth/index.js +4 -0
- package/dist/components/contact/ContactForm.svelte +148 -0
- package/dist/components/contact/ContactForm.svelte.d.ts +27 -0
- package/dist/components/contact/index.d.ts +2 -0
- package/dist/components/contact/index.js +1 -0
- package/dist/components/ui/Honeypot.svelte +44 -0
- package/dist/components/ui/Honeypot.svelte.d.ts +24 -0
- package/dist/components/ui/LocaleField.svelte +21 -0
- package/dist/components/ui/LocaleField.svelte.d.ts +16 -0
- package/dist/components/ui/NewsletterIntentField.svelte +31 -0
- package/dist/components/ui/NewsletterIntentField.svelte.d.ts +28 -0
- package/dist/components/ui/index.d.ts +3 -0
- package/dist/components/ui/index.js +3 -0
- package/dist/components/weeklies/CommentSection.svelte +229 -0
- package/dist/components/weeklies/CommentSection.svelte.d.ts +30 -0
- package/dist/components/weeklies/ReactionBar.svelte +170 -0
- package/dist/components/weeklies/ReactionBar.svelte.d.ts +31 -0
- package/dist/components/weeklies/index.d.ts +4 -0
- package/dist/components/weeklies/index.js +2 -0
- package/dist/config/messages.js +68 -0
- package/dist/config/types.d.ts +70 -0
- package/dist/content/index.d.ts +2 -1
- package/dist/content/index.js +1 -0
- package/dist/content/types.d.ts +34 -0
- package/dist/content/types.js +4 -1
- package/dist/forms/constraints.d.ts +35 -0
- package/dist/forms/constraints.js +19 -0
- package/dist/forms/index.d.ts +3 -0
- package/dist/forms/index.js +2 -0
- package/dist/forms/transport.d.ts +26 -0
- package/dist/forms/transport.js +32 -0
- package/dist/forms/types.d.ts +54 -0
- package/dist/forms/types.js +13 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/styles/base.css +41 -0
- package/dist/testing/remote-form.d.ts +45 -0
- package/dist/testing/remote-form.js +85 -0
- package/dist/utils/contact.d.ts +4 -0
- package/dist/utils/contact.js +15 -0
- package/package.json +28 -4
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hidden `locale` input every form carries. Without it a no-JS
|
|
3
|
+
* submission is stored under the base locale, because the unprefixed POST
|
|
4
|
+
* target resolves there before the locale cookie is consulted. Rendering it
|
|
5
|
+
* from one module makes the invariant enforceable instead of restated at
|
|
6
|
+
* every form.
|
|
7
|
+
*/
|
|
8
|
+
interface Props {
|
|
9
|
+
/** The form's `locale` field: `form.fields.locale`. */
|
|
10
|
+
field: {
|
|
11
|
+
as(type: 'hidden', value: string): Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
declare const LocaleField: import("svelte").Component<Props, {}, "">;
|
|
15
|
+
type LocaleField = ReturnType<typeof LocaleField>;
|
|
16
|
+
export default LocaleField;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { NEWSLETTER_INTENT_VALUE } from '../../forms/transport.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The hidden `newsletter` input that carries a newsletter intent through a
|
|
6
|
+
* form post. The fourth of the intent's four transports, and the last one to
|
|
7
|
+
* read its owner: the host app's cookie and URL query
|
|
8
|
+
* transports both take the value from `NEWSLETTER_INTENT_VALUE`,
|
|
9
|
+
* while the two markup sites spelled `'1'` and imported nothing.
|
|
10
|
+
*
|
|
11
|
+
* That matters because the schema is `z.literal(NEWSLETTER_INTENT_VALUE)`:
|
|
12
|
+
* changing the owner turns a hand-written literal into a submission that
|
|
13
|
+
* fails validation on a field the reader cannot see. Dropping the input
|
|
14
|
+
* instead loses the opt-in after the OAuth roundtrip, with no error and no
|
|
15
|
+
* red test — the silent-failure shape LocaleField and Honeypot each own a
|
|
16
|
+
* module for.
|
|
17
|
+
*
|
|
18
|
+
* Whether intent is being carried is the caller's fact and stays at the call
|
|
19
|
+
* site; how it is encoded is this module's. Signup is not a caller: its
|
|
20
|
+
* consent block binds `z.boolean()` through a visible checkbox, not the
|
|
21
|
+
* intent literal.
|
|
22
|
+
*/
|
|
23
|
+
interface Props {
|
|
24
|
+
/** The form's `newsletter` field: `form.fields.newsletter`. */
|
|
25
|
+
field: { as(type: 'hidden', value: string): Record<string, unknown> };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let { field }: Props = $props();
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<input {...field.as('hidden', NEWSLETTER_INTENT_VALUE)} />
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hidden `newsletter` input that carries a newsletter intent through a
|
|
3
|
+
* form post. The fourth of the intent's four transports, and the last one to
|
|
4
|
+
* read its owner: the host app's cookie and URL query
|
|
5
|
+
* transports both take the value from `NEWSLETTER_INTENT_VALUE`,
|
|
6
|
+
* while the two markup sites spelled `'1'` and imported nothing.
|
|
7
|
+
*
|
|
8
|
+
* That matters because the schema is `z.literal(NEWSLETTER_INTENT_VALUE)`:
|
|
9
|
+
* changing the owner turns a hand-written literal into a submission that
|
|
10
|
+
* fails validation on a field the reader cannot see. Dropping the input
|
|
11
|
+
* instead loses the opt-in after the OAuth roundtrip, with no error and no
|
|
12
|
+
* red test — the silent-failure shape LocaleField and Honeypot each own a
|
|
13
|
+
* module for.
|
|
14
|
+
*
|
|
15
|
+
* Whether intent is being carried is the caller's fact and stays at the call
|
|
16
|
+
* site; how it is encoded is this module's. Signup is not a caller: its
|
|
17
|
+
* consent block binds `z.boolean()` through a visible checkbox, not the
|
|
18
|
+
* intent literal.
|
|
19
|
+
*/
|
|
20
|
+
interface Props {
|
|
21
|
+
/** The form's `newsletter` field: `form.fields.newsletter`. */
|
|
22
|
+
field: {
|
|
23
|
+
as(type: 'hidden', value: string): Record<string, unknown>;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
declare const NewsletterIntentField: import("svelte").Component<Props, {}, "">;
|
|
27
|
+
type NewsletterIntentField = ReturnType<typeof NewsletterIntentField>;
|
|
28
|
+
export default NewsletterIntentField;
|
|
@@ -10,7 +10,10 @@ export { default as FormErrorFeedback } from './FormErrorFeedback.svelte';
|
|
|
10
10
|
export { default as FormFeedback } from './FormFeedback.svelte';
|
|
11
11
|
export { default as FormResultSlot } from './FormResultSlot.svelte';
|
|
12
12
|
export { default as GhostButton } from './GhostButton.svelte';
|
|
13
|
+
export { default as Honeypot } from './Honeypot.svelte';
|
|
13
14
|
export { default as Link } from './Link.svelte';
|
|
15
|
+
export { default as LocaleField } from './LocaleField.svelte';
|
|
16
|
+
export { default as NewsletterIntentField } from './NewsletterIntentField.svelte';
|
|
14
17
|
export { default as Pagination } from './Pagination.svelte';
|
|
15
18
|
export { default as RichText } from './RichText.svelte';
|
|
16
19
|
export { default as SearchInput } from './SearchInput.svelte';
|
|
@@ -10,7 +10,10 @@ export { default as FormErrorFeedback } from './FormErrorFeedback.svelte';
|
|
|
10
10
|
export { default as FormFeedback } from './FormFeedback.svelte';
|
|
11
11
|
export { default as FormResultSlot } from './FormResultSlot.svelte';
|
|
12
12
|
export { default as GhostButton } from './GhostButton.svelte';
|
|
13
|
+
export { default as Honeypot } from './Honeypot.svelte';
|
|
13
14
|
export { default as Link } from './Link.svelte';
|
|
15
|
+
export { default as LocaleField } from './LocaleField.svelte';
|
|
16
|
+
export { default as NewsletterIntentField } from './NewsletterIntentField.svelte';
|
|
14
17
|
export { default as Pagination } from './Pagination.svelte';
|
|
15
18
|
export { default as RichText } from './RichText.svelte';
|
|
16
19
|
export { default as SearchInput } from './SearchInput.svelte';
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { FormResultOf, RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
3
|
+
|
|
4
|
+
/** One preflighted comment form (the main box, or one reply box). */
|
|
5
|
+
export type CommentFormInstance = RemoteFormInstance<
|
|
6
|
+
{ body: RemoteField; weeklySlug: RemoteField; parentId: RemoteField },
|
|
7
|
+
FormResultOf
|
|
8
|
+
>;
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import { getUiConfig } from '../../config/context.js';
|
|
13
|
+
import { COMMENT_BODY } from '../../forms/constraints.js';
|
|
14
|
+
import type { CommentData, CommentThreadData } from '../../content/types.js';
|
|
15
|
+
import Button from '../ui/Button.svelte';
|
|
16
|
+
import DateText from '../ui/DateText.svelte';
|
|
17
|
+
import Field from '../ui/Field.svelte';
|
|
18
|
+
import FormErrorFeedback from '../ui/FormErrorFeedback.svelte';
|
|
19
|
+
import Link from '../ui/Link.svelte';
|
|
20
|
+
import ReactionBar, { type ReactionBarForms } from './ReactionBar.svelte';
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
comments: CommentThreadData[];
|
|
24
|
+
weeklySlug: string;
|
|
25
|
+
isLoggedIn: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* The main comment form, preflighted by the host page. One RemoteForm
|
|
28
|
+
* instance can only attach to one <form> at a time, so every reply form
|
|
29
|
+
* gets its own through `replyFormFor` — the host builds each with
|
|
30
|
+
* `commentForm.for(threadId).preflight(schema)`, keeping schema and
|
|
31
|
+
* keying on its side of the seam. Every instance must be preflighted
|
|
32
|
+
* itself: a keyed form falls back to the base schema only while the
|
|
33
|
+
* base form renders, so each says so explicitly.
|
|
34
|
+
*/
|
|
35
|
+
commentForm: CommentFormInstance;
|
|
36
|
+
replyFormFor: (threadId: string) => CommentFormInstance;
|
|
37
|
+
/** The reaction forms, handed through to each comment's ReactionBar. */
|
|
38
|
+
reactionForms: ReactionBarForms;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let { comments, weeklySlug, isLoggedIn, commentForm, replyFormFor, reactionForms }: Props =
|
|
42
|
+
$props();
|
|
43
|
+
|
|
44
|
+
const config = getUiConfig();
|
|
45
|
+
const msg = $derived(config.messages);
|
|
46
|
+
|
|
47
|
+
// No custom enhance: kit's default post-submit behavior already resets
|
|
48
|
+
// the form and re-runs the page's load functions in place (no navigation,
|
|
49
|
+
// scroll preserved).
|
|
50
|
+
const main = $derived(commentForm);
|
|
51
|
+
|
|
52
|
+
const errorMessages = $derived({ unauthenticated: msg.comments_loginPrompt() });
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
{#snippet commentMeta(comment: CommentData)}
|
|
56
|
+
<p class="meta">
|
|
57
|
+
<strong>{comment.displayName}</strong>
|
|
58
|
+
<DateText value={comment.createdAt} />
|
|
59
|
+
</p>
|
|
60
|
+
<p class="body">{comment.body}</p>
|
|
61
|
+
<ReactionBar
|
|
62
|
+
reactions={comment.reactions}
|
|
63
|
+
target={{ kind: 'comment', id: comment.id }}
|
|
64
|
+
{isLoggedIn}
|
|
65
|
+
forms={reactionForms}
|
|
66
|
+
/>
|
|
67
|
+
{/snippet}
|
|
68
|
+
|
|
69
|
+
<section aria-labelledby="comments-heading">
|
|
70
|
+
<h2 class="subsection-heading" id="comments-heading">{msg.comments_title()}</h2>
|
|
71
|
+
|
|
72
|
+
{#if comments.length === 0}
|
|
73
|
+
<p class="empty">{msg.comments_empty()}</p>
|
|
74
|
+
{:else}
|
|
75
|
+
<ul class="list">
|
|
76
|
+
{#each comments as thread (thread.id)}
|
|
77
|
+
<li>
|
|
78
|
+
{@render commentMeta(thread)}
|
|
79
|
+
|
|
80
|
+
{#if thread.replies.length > 0}
|
|
81
|
+
<ul class="replies">
|
|
82
|
+
{#each thread.replies as reply (reply.id)}
|
|
83
|
+
<li>{@render commentMeta(reply)}</li>
|
|
84
|
+
{/each}
|
|
85
|
+
</ul>
|
|
86
|
+
{/if}
|
|
87
|
+
|
|
88
|
+
{#if isLoggedIn}
|
|
89
|
+
{@const reply = replyFormFor(String(thread.id))}
|
|
90
|
+
<!-- Native disclosure: the reply form opens without JS. -->
|
|
91
|
+
<details class="reply">
|
|
92
|
+
<summary>{msg.comments_replyLabel()}</summary>
|
|
93
|
+
<form class="form-stack" {...reply}>
|
|
94
|
+
<Field
|
|
95
|
+
id="reply-body-{thread.id}"
|
|
96
|
+
label={msg.comments_bodyLabel()}
|
|
97
|
+
field={reply.fields.body}
|
|
98
|
+
constraint={COMMENT_BODY}
|
|
99
|
+
hideLabel
|
|
100
|
+
>
|
|
101
|
+
{#snippet children(attrs)}
|
|
102
|
+
<textarea
|
|
103
|
+
class="control"
|
|
104
|
+
required
|
|
105
|
+
rows="3"
|
|
106
|
+
{...attrs}
|
|
107
|
+
{...reply.fields.body.as('text')}
|
|
108
|
+
></textarea>
|
|
109
|
+
{/snippet}
|
|
110
|
+
</Field>
|
|
111
|
+
<input {...reply.fields.weeklySlug.as('hidden', weeklySlug)} />
|
|
112
|
+
<input {...reply.fields.parentId.as('hidden', String(thread.id))} />
|
|
113
|
+
<div class="actions">
|
|
114
|
+
<Button type="submit" pending={reply.pending}>
|
|
115
|
+
{msg.comments_replySubmit()}
|
|
116
|
+
</Button>
|
|
117
|
+
</div>
|
|
118
|
+
<FormErrorFeedback result={reply.result} messages={errorMessages} />
|
|
119
|
+
</form>
|
|
120
|
+
</details>
|
|
121
|
+
{/if}
|
|
122
|
+
</li>
|
|
123
|
+
{/each}
|
|
124
|
+
</ul>
|
|
125
|
+
{/if}
|
|
126
|
+
|
|
127
|
+
{#if isLoggedIn}
|
|
128
|
+
<form class="form-stack" {...main}>
|
|
129
|
+
<Field
|
|
130
|
+
id="comment-body"
|
|
131
|
+
label={msg.comments_bodyLabel()}
|
|
132
|
+
field={main.fields.body}
|
|
133
|
+
constraint={COMMENT_BODY}
|
|
134
|
+
>
|
|
135
|
+
{#snippet children(attrs)}
|
|
136
|
+
<textarea class="control" required rows="4" {...attrs} {...main.fields.body.as('text')}
|
|
137
|
+
></textarea>
|
|
138
|
+
{/snippet}
|
|
139
|
+
</Field>
|
|
140
|
+
|
|
141
|
+
<input {...main.fields.weeklySlug.as('hidden', weeklySlug)} />
|
|
142
|
+
|
|
143
|
+
<div class="actions">
|
|
144
|
+
<Button type="submit" pending={main.pending}>{msg.comments_submit()}</Button>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<FormErrorFeedback result={main.result} messages={errorMessages} />
|
|
148
|
+
</form>
|
|
149
|
+
{:else}
|
|
150
|
+
<p class="login-prompt">
|
|
151
|
+
{msg.comments_loginPrompt()}
|
|
152
|
+
<Link href="/login">{msg.comments_loginLink()}</Link>
|
|
153
|
+
·
|
|
154
|
+
<Link href="/signup">{msg.comments_signupLink()}</Link>
|
|
155
|
+
</p>
|
|
156
|
+
{/if}
|
|
157
|
+
</section>
|
|
158
|
+
|
|
159
|
+
<style>
|
|
160
|
+
.empty,
|
|
161
|
+
.login-prompt {
|
|
162
|
+
color: var(--color-ink-secondary);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.list {
|
|
166
|
+
list-style: none;
|
|
167
|
+
padding: 0;
|
|
168
|
+
margin: 0 0 var(--space-4);
|
|
169
|
+
display: flex;
|
|
170
|
+
flex-direction: column;
|
|
171
|
+
gap: var(--space-3);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.list > li {
|
|
175
|
+
border-bottom: 1px solid var(--color-hairline);
|
|
176
|
+
padding-bottom: var(--space-3);
|
|
177
|
+
display: flex;
|
|
178
|
+
flex-direction: column;
|
|
179
|
+
gap: var(--space-2);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.replies {
|
|
183
|
+
list-style: none;
|
|
184
|
+
margin: 0;
|
|
185
|
+
padding: 0 0 0 var(--space-4);
|
|
186
|
+
border-left: 2px solid var(--color-hairline);
|
|
187
|
+
display: flex;
|
|
188
|
+
flex-direction: column;
|
|
189
|
+
gap: var(--space-3);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.replies li {
|
|
193
|
+
display: flex;
|
|
194
|
+
flex-direction: column;
|
|
195
|
+
gap: var(--space-2);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.meta {
|
|
199
|
+
display: flex;
|
|
200
|
+
gap: var(--space-2);
|
|
201
|
+
align-items: baseline;
|
|
202
|
+
margin: 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.body {
|
|
206
|
+
margin: 0;
|
|
207
|
+
white-space: pre-line;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.reply summary {
|
|
211
|
+
cursor: pointer;
|
|
212
|
+
color: var(--color-ink-secondary);
|
|
213
|
+
font-size: var(--text-sm);
|
|
214
|
+
font-weight: 600;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.reply form {
|
|
218
|
+
margin-top: var(--space-2);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
form {
|
|
222
|
+
max-width: 32rem;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
textarea {
|
|
226
|
+
padding: var(--space-2);
|
|
227
|
+
resize: vertical;
|
|
228
|
+
}
|
|
229
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FormResultOf, RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
2
|
+
/** One preflighted comment form (the main box, or one reply box). */
|
|
3
|
+
export type CommentFormInstance = RemoteFormInstance<{
|
|
4
|
+
body: RemoteField;
|
|
5
|
+
weeklySlug: RemoteField;
|
|
6
|
+
parentId: RemoteField;
|
|
7
|
+
}, FormResultOf>;
|
|
8
|
+
import type { CommentThreadData } from '../../content/types.js';
|
|
9
|
+
import { type ReactionBarForms } from './ReactionBar.svelte';
|
|
10
|
+
interface Props {
|
|
11
|
+
comments: CommentThreadData[];
|
|
12
|
+
weeklySlug: string;
|
|
13
|
+
isLoggedIn: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The main comment form, preflighted by the host page. One RemoteForm
|
|
16
|
+
* instance can only attach to one <form> at a time, so every reply form
|
|
17
|
+
* gets its own through `replyFormFor` — the host builds each with
|
|
18
|
+
* `commentForm.for(threadId).preflight(schema)`, keeping schema and
|
|
19
|
+
* keying on its side of the seam. Every instance must be preflighted
|
|
20
|
+
* itself: a keyed form falls back to the base schema only while the
|
|
21
|
+
* base form renders, so each says so explicitly.
|
|
22
|
+
*/
|
|
23
|
+
commentForm: CommentFormInstance;
|
|
24
|
+
replyFormFor: (threadId: string) => CommentFormInstance;
|
|
25
|
+
/** The reaction forms, handed through to each comment's ReactionBar. */
|
|
26
|
+
reactionForms: ReactionBarForms;
|
|
27
|
+
}
|
|
28
|
+
declare const CommentSection: import("svelte").Component<Props, {}, "">;
|
|
29
|
+
type CommentSection = ReturnType<typeof CommentSection>;
|
|
30
|
+
export default CommentSection;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
FormResultOf,
|
|
4
|
+
KeyedRemoteForms,
|
|
5
|
+
RemoteField,
|
|
6
|
+
RemoteFormInstance
|
|
7
|
+
} from '../../forms/types.js';
|
|
8
|
+
|
|
9
|
+
/** One reaction chip's form: the target id field plus the reaction. */
|
|
10
|
+
export type WeeklyReactionChip = RemoteFormInstance<
|
|
11
|
+
{ weeklySlug: RemoteField; reaction: RemoteField },
|
|
12
|
+
FormResultOf
|
|
13
|
+
>;
|
|
14
|
+
export type CommentReactionChip = RemoteFormInstance<
|
|
15
|
+
{ commentId: RemoteField; reaction: RemoteField },
|
|
16
|
+
FormResultOf
|
|
17
|
+
>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The two keyed remote forms a bar toggles through — every chip is its own
|
|
21
|
+
* <form>, so each needs its own instance via `.for(key)`. Bundled so
|
|
22
|
+
* CommentSection can hand them through per comment without naming both.
|
|
23
|
+
*/
|
|
24
|
+
export interface ReactionBarForms {
|
|
25
|
+
weeklyReactionForm: KeyedRemoteForms<WeeklyReactionChip>;
|
|
26
|
+
commentReactionForm: KeyedRemoteForms<CommentReactionChip>;
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<script lang="ts">
|
|
31
|
+
import { getUiConfig } from '../../config/context.js';
|
|
32
|
+
import type { Reaction, ReactionSummary, ReactionTarget } from '../../content/types.js';
|
|
33
|
+
import FormErrorFeedback from '../ui/FormErrorFeedback.svelte';
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
reactions: ReactionSummary[];
|
|
37
|
+
/** The weekly or comment these reactions belong to. */
|
|
38
|
+
target: ReactionTarget;
|
|
39
|
+
isLoggedIn: boolean;
|
|
40
|
+
/** The keyed remote forms, passed by the host page. */
|
|
41
|
+
forms: ReactionBarForms;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let { reactions, target, isLoggedIn, forms }: Props = $props();
|
|
45
|
+
|
|
46
|
+
const config = getUiConfig();
|
|
47
|
+
const msg = $derived(config.messages);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Both presentation facts of a reaction, in one exhaustive record — the
|
|
51
|
+
* shape milestoneCategoryLabel uses, for the same reason. A fourth Reaction
|
|
52
|
+
* fails the build here instead of shipping the wrong screen-reader name.
|
|
53
|
+
*/
|
|
54
|
+
const REACTION = $derived<Record<Reaction, { emoji: string; label: () => string }>>({
|
|
55
|
+
like: { emoji: '👍', label: msg.reaction_like },
|
|
56
|
+
love: { emoji: '❤️', label: msg.reaction_love },
|
|
57
|
+
clap: { emoji: '👏', label: msg.reaction_clap }
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The chip's form and the one field that identifies what it reacts to.
|
|
62
|
+
* The two targets are stored apart, so each names its own form and its own
|
|
63
|
+
* id field, and nothing else about a chip differs.
|
|
64
|
+
*
|
|
65
|
+
* `on` is a parameter rather than the prop so the discriminant narrows here.
|
|
66
|
+
*/
|
|
67
|
+
function chipFor(reaction: Reaction, on: ReactionTarget) {
|
|
68
|
+
if (on.kind === 'weekly') {
|
|
69
|
+
const chip = forms.weeklyReactionForm.for(`${on.slug}|${reaction}`);
|
|
70
|
+
return { chip, idField: chip.fields.weeklySlug.as('hidden', on.slug) };
|
|
71
|
+
}
|
|
72
|
+
const chip = forms.commentReactionForm.for(`${on.id}|${reaction}`);
|
|
73
|
+
return { chip, idField: chip.fields.commentId.as('hidden', String(on.id)) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The first failure across the bar's chips. One alert for the bar rather
|
|
78
|
+
* than one per chip: three stacked alerts for what is always the same
|
|
79
|
+
* failure would be read out three times.
|
|
80
|
+
*/
|
|
81
|
+
const failure = $derived(
|
|
82
|
+
isLoggedIn
|
|
83
|
+
? reactions
|
|
84
|
+
.map((entry) => chipFor(entry.reaction, target).chip.result)
|
|
85
|
+
.find((result) => result && !result.ok)
|
|
86
|
+
: undefined
|
|
87
|
+
);
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
{#snippet chipContent(entry: (typeof reactions)[number])}
|
|
91
|
+
<span aria-hidden="true">{REACTION[entry.reaction].emoji}</span>
|
|
92
|
+
<span class="visually-hidden">{REACTION[entry.reaction].label()}</span>
|
|
93
|
+
{#if entry.count > 0}<span class="count">{entry.count}</span>{/if}
|
|
94
|
+
{/snippet}
|
|
95
|
+
|
|
96
|
+
<div class="bar" role="group" aria-label={msg.reactions_groupLabel()}>
|
|
97
|
+
{#each reactions as entry (entry.reaction)}
|
|
98
|
+
{#if isLoggedIn}
|
|
99
|
+
{@const { chip, idField } = chipFor(entry.reaction, target)}
|
|
100
|
+
<!-- One tiny form per reaction: toggling works without JavaScript. -->
|
|
101
|
+
<form {...chip}>
|
|
102
|
+
<input {...idField} />
|
|
103
|
+
<input {...chip.fields.reaction.as('hidden', entry.reaction)} />
|
|
104
|
+
<!-- aria-busy, not disabled: a disabled control is unfocusable, so
|
|
105
|
+
disabling a toggle mid-flight drops focus off the chip the reader
|
|
106
|
+
just activated. That is what makes this different from a submit
|
|
107
|
+
whose form is replaced or which redirects away. -->
|
|
108
|
+
<button
|
|
109
|
+
type="submit"
|
|
110
|
+
class="chip"
|
|
111
|
+
class:mine={entry.mine}
|
|
112
|
+
aria-pressed={entry.mine}
|
|
113
|
+
aria-busy={chip.pending > 0 || undefined}
|
|
114
|
+
>
|
|
115
|
+
{@render chipContent(entry)}
|
|
116
|
+
</button>
|
|
117
|
+
</form>
|
|
118
|
+
{:else if entry.count > 0}
|
|
119
|
+
<span class="chip static" title={REACTION[entry.reaction].label()}>
|
|
120
|
+
{@render chipContent(entry)}
|
|
121
|
+
</span>
|
|
122
|
+
{/if}
|
|
123
|
+
{/each}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<FormErrorFeedback result={failure} />
|
|
127
|
+
|
|
128
|
+
<style>
|
|
129
|
+
.bar {
|
|
130
|
+
display: flex;
|
|
131
|
+
gap: var(--space-2);
|
|
132
|
+
align-items: center;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
form {
|
|
136
|
+
display: contents;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.chip {
|
|
140
|
+
display: inline-flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
gap: var(--space-1);
|
|
143
|
+
border: 1px solid var(--color-hairline);
|
|
144
|
+
border-radius: 999px;
|
|
145
|
+
background: var(--color-surface);
|
|
146
|
+
padding: 0.15rem var(--space-2);
|
|
147
|
+
font-size: var(--text-sm);
|
|
148
|
+
color: var(--color-ink-secondary);
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
transition: border-color var(--transition-fast);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
button.chip:hover {
|
|
154
|
+
border-color: var(--color-brand);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.chip.mine {
|
|
158
|
+
border-color: var(--color-brand);
|
|
159
|
+
background: color-mix(in srgb, var(--color-brand) 10%, var(--color-surface));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.chip.static {
|
|
163
|
+
cursor: default;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.count {
|
|
167
|
+
font-variant-numeric: tabular-nums;
|
|
168
|
+
font-weight: 600;
|
|
169
|
+
}
|
|
170
|
+
</style>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { FormResultOf, KeyedRemoteForms, RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
2
|
+
/** One reaction chip's form: the target id field plus the reaction. */
|
|
3
|
+
export type WeeklyReactionChip = RemoteFormInstance<{
|
|
4
|
+
weeklySlug: RemoteField;
|
|
5
|
+
reaction: RemoteField;
|
|
6
|
+
}, FormResultOf>;
|
|
7
|
+
export type CommentReactionChip = RemoteFormInstance<{
|
|
8
|
+
commentId: RemoteField;
|
|
9
|
+
reaction: RemoteField;
|
|
10
|
+
}, FormResultOf>;
|
|
11
|
+
/**
|
|
12
|
+
* The two keyed remote forms a bar toggles through — every chip is its own
|
|
13
|
+
* <form>, so each needs its own instance via `.for(key)`. Bundled so
|
|
14
|
+
* CommentSection can hand them through per comment without naming both.
|
|
15
|
+
*/
|
|
16
|
+
export interface ReactionBarForms {
|
|
17
|
+
weeklyReactionForm: KeyedRemoteForms<WeeklyReactionChip>;
|
|
18
|
+
commentReactionForm: KeyedRemoteForms<CommentReactionChip>;
|
|
19
|
+
}
|
|
20
|
+
import type { ReactionSummary, ReactionTarget } from '../../content/types.js';
|
|
21
|
+
interface Props {
|
|
22
|
+
reactions: ReactionSummary[];
|
|
23
|
+
/** The weekly or comment these reactions belong to. */
|
|
24
|
+
target: ReactionTarget;
|
|
25
|
+
isLoggedIn: boolean;
|
|
26
|
+
/** The keyed remote forms, passed by the host page. */
|
|
27
|
+
forms: ReactionBarForms;
|
|
28
|
+
}
|
|
29
|
+
declare const ReactionBar: import("svelte").Component<Props, {}, "">;
|
|
30
|
+
type ReactionBar = ReturnType<typeof ReactionBar>;
|
|
31
|
+
export default ReactionBar;
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export { default as CommentSection } from './CommentSection.svelte';
|
|
2
|
+
export type { CommentFormInstance } from './CommentSection.svelte';
|
|
3
|
+
export { default as ReactionBar } from './ReactionBar.svelte';
|
|
4
|
+
export type { CommentReactionChip, ReactionBarForms, WeeklyReactionChip } from './ReactionBar.svelte';
|
|
1
5
|
export { default as SortSelect } from './SortSelect.svelte';
|
|
2
6
|
export { default as WeeklieCard } from './WeeklieCard.svelte';
|
|
3
7
|
export type { WeeklyEditMap } from './WeeklieCard.svelte';
|
package/dist/config/messages.js
CHANGED
|
@@ -30,6 +30,74 @@ export const defaultMessages = {
|
|
|
30
30
|
footer_rights: () => 'Fundació Visualization for Transparency. Tots els drets reservats.',
|
|
31
31
|
jobs_empty: () => 'Ara mateix no tenim cap oferta oberta.',
|
|
32
32
|
jobs_newsletterNudge: () => 'Res que encaixi amb el teu perfil? Subscriu-te al butlletí per estar al dia.',
|
|
33
|
+
login_title: () => 'Inicia la sessió',
|
|
34
|
+
login_emailLabel: () => 'Correu electrònic',
|
|
35
|
+
login_passwordLabel: () => 'Contrasenya',
|
|
36
|
+
login_submit: () => 'Entra',
|
|
37
|
+
login_divider: () => 'o',
|
|
38
|
+
login_magicLinkSubmit: () => "Envia'm un enllaç d'accés",
|
|
39
|
+
login_magicLinkSuccess: () => "Si el correu existeix, hi hem enviat un enllaç d'accés.",
|
|
40
|
+
login_googleSubmit: () => 'Continua amb Google',
|
|
41
|
+
login_error_invalidCredentials: () => 'Credencials incorrectes.',
|
|
42
|
+
signup_title: () => 'Crea un compte',
|
|
43
|
+
signup_nameLabel: () => 'Nom visible',
|
|
44
|
+
signup_passwordLabel: () => 'Contrasenya (mínim 8 caràcters)',
|
|
45
|
+
signup_newsletterLabel: () => 'Vull rebre el butlletí de la fundació.',
|
|
46
|
+
signup_submit: () => 'Crea el compte',
|
|
47
|
+
signup_success: () => "Revisa el correu: t'hem enviat un enllaç per confirmar el compte.",
|
|
48
|
+
signup_termsPre: () => 'Accepto els',
|
|
49
|
+
signup_termsLink: () => 'termes i condicions',
|
|
50
|
+
auth_tabsLabel: () => 'Accés al compte',
|
|
51
|
+
auth_legalNotePre: () => 'En continuar amb Google, acceptes els',
|
|
52
|
+
account_nameHeading: () => 'Nom públic',
|
|
53
|
+
account_nameLabel: () => 'Nom',
|
|
54
|
+
account_nameSubmit: () => 'Desa el nom',
|
|
55
|
+
account_nameSuccess: () => 'Nom actualitzat.',
|
|
56
|
+
account_newsletterHeading: () => 'Butlletí',
|
|
57
|
+
account_newsletterStatusOn: ({ email }) => `Estàs subscrit amb ${email}.`,
|
|
58
|
+
account_newsletterStatusOff: () => 'No estàs subscrit al butlletí.',
|
|
59
|
+
account_newsletterSubscribe: () => "Subscriu-m'hi",
|
|
60
|
+
account_newsletterUnsubscribe: () => 'Cancel·la la subscripció',
|
|
61
|
+
account_newsletterSuccessOn: () => 'Subscripció activada.',
|
|
62
|
+
account_newsletterSuccessOff: () => 'Subscripció cancel·lada.',
|
|
63
|
+
account_dataHeading: () => 'Les meves dades',
|
|
64
|
+
account_dataDescription: () => 'Descarrega una còpia de les teves dades (perfil, comentaris i subscripció) en format JSON.',
|
|
65
|
+
account_dataDownload: () => 'Descarrega les meves dades',
|
|
66
|
+
account_logout: () => 'Tanca la sessió',
|
|
67
|
+
account_deleteHeading: () => 'Elimina el compte',
|
|
68
|
+
account_deleteWarning: () => "Aquesta acció és permanent: s'eliminaran el compte, els comentaris i la subscripció al butlletí.",
|
|
69
|
+
account_deleteConfirmLabel: () => 'Entenc que aquesta acció no es pot desfer.',
|
|
70
|
+
account_deleteSubmit: () => 'Elimina el meu compte',
|
|
71
|
+
account_error_unavailable: () => "L'eliminació del compte no està disponible ara mateix. Escriu-nos i l'eliminarem manualment.",
|
|
72
|
+
newsletter_title: () => 'Butlletí',
|
|
73
|
+
newsletter_intro: () => 'Rep les novetats de la fundació al teu correu.',
|
|
74
|
+
newsletter_promptLoggedOut: () => 'El butlletí és per a persones registrades: el teu correu ja queda verificat i no en necessitem cap més.',
|
|
75
|
+
newsletter_subscribedNote: () => 'Ja estàs subscrit al butlletí. Gestiona-ho des de',
|
|
76
|
+
comments_title: () => 'Comentaris',
|
|
77
|
+
comments_empty: () => 'Encara no hi ha comentaris. Sigues el primer!',
|
|
78
|
+
comments_bodyLabel: () => 'El teu comentari',
|
|
79
|
+
comments_submit: () => 'Publica el comentari',
|
|
80
|
+
comments_replyLabel: () => 'Respon',
|
|
81
|
+
comments_replySubmit: () => 'Publica la resposta',
|
|
82
|
+
comments_loginPrompt: () => 'Per comentar cal iniciar la sessió.',
|
|
83
|
+
comments_loginLink: () => 'Inicia la sessió',
|
|
84
|
+
comments_signupLink: () => 'Crea un compte',
|
|
85
|
+
reaction_like: () => "M'agrada",
|
|
86
|
+
reaction_love: () => "M'encanta",
|
|
87
|
+
reaction_clap: () => 'Aplaudiment',
|
|
88
|
+
reactions_groupLabel: () => 'Reaccions',
|
|
89
|
+
contact_categoryLabel: () => 'Motiu',
|
|
90
|
+
contact_nameLabel: () => 'Nom',
|
|
91
|
+
contact_emailLabel: () => 'Correu electrònic',
|
|
92
|
+
contact_messageLabel: () => 'Missatge',
|
|
93
|
+
contact_submit: () => 'Envia',
|
|
94
|
+
contact_success: () => 'Missatge enviat. Et respondrem aviat!',
|
|
95
|
+
contact_invalid: () => 'Revisa els camps marcats.',
|
|
96
|
+
contact_category_collaborate: () => 'Vull col·laborar',
|
|
97
|
+
contact_category_event: () => 'Us vull en un esdeveniment',
|
|
98
|
+
contact_category_press: () => 'Premsa',
|
|
99
|
+
contact_category_brand: () => 'Promoció de marca',
|
|
100
|
+
contact_category_other: () => 'Preguntes, idees o només saludar',
|
|
33
101
|
category_foundation: () => 'Fundació',
|
|
34
102
|
category_lab: () => 'Laboratori',
|
|
35
103
|
category_education: () => 'Educació',
|