@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,224 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { FormResultOf, RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
3
|
+
|
|
4
|
+
/** The preflighted display-name form. */
|
|
5
|
+
export type UpdateNameFormInstance = RemoteFormInstance<{ displayName: RemoteField }>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The newsletter toggle; a success names which way it toggled. The reason
|
|
9
|
+
* is typed open — the host's result union may carry other successes.
|
|
10
|
+
*/
|
|
11
|
+
export type NewsletterToggleFormInstance = RemoteFormInstance<
|
|
12
|
+
{ action: RemoteField; locale: RemoteField },
|
|
13
|
+
FormResultOf<{ reason: string }>
|
|
14
|
+
>;
|
|
15
|
+
|
|
16
|
+
export type DeleteAccountFormInstance = RemoteFormInstance<{
|
|
17
|
+
confirm: RemoteField;
|
|
18
|
+
locale: RemoteField;
|
|
19
|
+
}>;
|
|
20
|
+
|
|
21
|
+
export type LogoutFormInstance = RemoteFormInstance<{ locale: RemoteField }>;
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<script lang="ts">
|
|
25
|
+
import { getUiConfig } from '../../config/context.js';
|
|
26
|
+
import { DISPLAY_NAME } from '../../forms/constraints.js';
|
|
27
|
+
import Button from '../ui/Button.svelte';
|
|
28
|
+
import Field from '../ui/Field.svelte';
|
|
29
|
+
import FormErrorFeedback from '../ui/FormErrorFeedback.svelte';
|
|
30
|
+
import FormResultSlot from '../ui/FormResultSlot.svelte';
|
|
31
|
+
import Link from '../ui/Link.svelte';
|
|
32
|
+
import LocaleField from '../ui/LocaleField.svelte';
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
displayName: string;
|
|
36
|
+
email: string | null;
|
|
37
|
+
isSubscribed: boolean;
|
|
38
|
+
/** False when the server lacks the admin key for account deletion. */
|
|
39
|
+
canDelete: boolean;
|
|
40
|
+
/** The remote forms, passed by the host page (updateName preflighted). */
|
|
41
|
+
updateNameForm: UpdateNameFormInstance;
|
|
42
|
+
newsletterToggleForm: NewsletterToggleFormInstance;
|
|
43
|
+
deleteAccountForm: DeleteAccountFormInstance;
|
|
44
|
+
logoutForm: LogoutFormInstance;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let {
|
|
48
|
+
displayName,
|
|
49
|
+
email,
|
|
50
|
+
isSubscribed,
|
|
51
|
+
canDelete,
|
|
52
|
+
updateNameForm,
|
|
53
|
+
newsletterToggleForm,
|
|
54
|
+
deleteAccountForm,
|
|
55
|
+
logoutForm
|
|
56
|
+
}: Props = $props();
|
|
57
|
+
|
|
58
|
+
const config = getUiConfig();
|
|
59
|
+
const msg = $derived(config.messages);
|
|
60
|
+
|
|
61
|
+
const name = $derived(updateNameForm);
|
|
62
|
+
const newsletter = $derived(newsletterToggleForm);
|
|
63
|
+
const del = $derived(deleteAccountForm);
|
|
64
|
+
|
|
65
|
+
const newsletterSuccess = $derived(
|
|
66
|
+
newsletter.result?.ok && newsletter.result.reason === 'subscribed'
|
|
67
|
+
? msg.account_newsletterSuccessOn()
|
|
68
|
+
: msg.account_newsletterSuccessOff()
|
|
69
|
+
);
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<div class="panel">
|
|
73
|
+
<section aria-labelledby="account-name-heading">
|
|
74
|
+
<h2 class="subsection-heading" id="account-name-heading">{msg.account_nameHeading()}</h2>
|
|
75
|
+
<form class="form-stack" {...name}>
|
|
76
|
+
<Field
|
|
77
|
+
id="account-name"
|
|
78
|
+
label={msg.account_nameLabel()}
|
|
79
|
+
field={name.fields.displayName}
|
|
80
|
+
constraint={DISPLAY_NAME}
|
|
81
|
+
>
|
|
82
|
+
{#snippet children(attrs)}
|
|
83
|
+
<input
|
|
84
|
+
class="control"
|
|
85
|
+
required
|
|
86
|
+
autocomplete="nickname"
|
|
87
|
+
{...attrs}
|
|
88
|
+
{...name.fields.displayName.as('text', displayName)}
|
|
89
|
+
/>
|
|
90
|
+
{/snippet}
|
|
91
|
+
</Field>
|
|
92
|
+
<div class="actions">
|
|
93
|
+
<Button type="submit" pending={name.pending}>{msg.account_nameSubmit()}</Button>
|
|
94
|
+
</div>
|
|
95
|
+
<FormResultSlot result={name.result} successMessage={msg.account_nameSuccess()} />
|
|
96
|
+
</form>
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
{#if email}
|
|
100
|
+
<section aria-labelledby="account-newsletter-heading">
|
|
101
|
+
<h2 class="subsection-heading" id="account-newsletter-heading">
|
|
102
|
+
{msg.account_newsletterHeading()}
|
|
103
|
+
</h2>
|
|
104
|
+
<p class="status">
|
|
105
|
+
{isSubscribed
|
|
106
|
+
? msg.account_newsletterStatusOn({ email })
|
|
107
|
+
: msg.account_newsletterStatusOff()}
|
|
108
|
+
</p>
|
|
109
|
+
<form class="form-stack" {...newsletter}>
|
|
110
|
+
<input
|
|
111
|
+
{...newsletter.fields.action.as('hidden', isSubscribed ? 'unsubscribe' : 'subscribe')}
|
|
112
|
+
/>
|
|
113
|
+
<LocaleField field={newsletter.fields.locale} />
|
|
114
|
+
<div class="actions">
|
|
115
|
+
<Button type="submit" pending={newsletter.pending}>
|
|
116
|
+
{isSubscribed ? msg.account_newsletterUnsubscribe() : msg.account_newsletterSubscribe()}
|
|
117
|
+
</Button>
|
|
118
|
+
</div>
|
|
119
|
+
<FormResultSlot result={newsletter.result} successMessage={newsletterSuccess} />
|
|
120
|
+
</form>
|
|
121
|
+
</section>
|
|
122
|
+
{/if}
|
|
123
|
+
|
|
124
|
+
<section aria-labelledby="account-data-heading">
|
|
125
|
+
<h2 class="subsection-heading" id="account-data-heading">{msg.account_dataHeading()}</h2>
|
|
126
|
+
<p class="status">{msg.account_dataDescription()}</p>
|
|
127
|
+
<p>
|
|
128
|
+
<Link class="download" href="/account/data" download>{msg.account_dataDownload()}</Link>
|
|
129
|
+
</p>
|
|
130
|
+
</section>
|
|
131
|
+
|
|
132
|
+
<section aria-labelledby="account-logout-heading">
|
|
133
|
+
<h2 class="subsection-heading" id="account-logout-heading">{msg.account_logout()}</h2>
|
|
134
|
+
<form class="form-stack" {...logoutForm}>
|
|
135
|
+
<LocaleField field={logoutForm.fields.locale} />
|
|
136
|
+
<div class="actions">
|
|
137
|
+
<Button type="submit" pending={logoutForm.pending}>{msg.account_logout()}</Button>
|
|
138
|
+
</div>
|
|
139
|
+
<!-- Success redirects, so only a failure ever renders here — and a
|
|
140
|
+
logout that failed leaves the reader signed in with nothing said. -->
|
|
141
|
+
<FormErrorFeedback result={logoutForm.result} />
|
|
142
|
+
</form>
|
|
143
|
+
</section>
|
|
144
|
+
|
|
145
|
+
<section class="danger" aria-labelledby="account-delete-heading">
|
|
146
|
+
<h2 class="subsection-heading" id="account-delete-heading">{msg.account_deleteHeading()}</h2>
|
|
147
|
+
<p class="status">{msg.account_deleteWarning()}</p>
|
|
148
|
+
{#if canDelete}
|
|
149
|
+
<form class="form-stack" {...del}>
|
|
150
|
+
<label class="confirm">
|
|
151
|
+
<input required {...del.fields.confirm.as('checkbox')} />
|
|
152
|
+
{msg.account_deleteConfirmLabel()}
|
|
153
|
+
</label>
|
|
154
|
+
<LocaleField field={del.fields.locale} />
|
|
155
|
+
<div class="actions">
|
|
156
|
+
<Button type="submit" pending={del.pending}>{msg.account_deleteSubmit()}</Button>
|
|
157
|
+
</div>
|
|
158
|
+
<FormErrorFeedback
|
|
159
|
+
result={del.result}
|
|
160
|
+
messages={{ unavailable: msg.account_error_unavailable() }}
|
|
161
|
+
/>
|
|
162
|
+
</form>
|
|
163
|
+
{:else}
|
|
164
|
+
<p class="status">{msg.account_error_unavailable()}</p>
|
|
165
|
+
{/if}
|
|
166
|
+
</section>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<style>
|
|
170
|
+
.panel {
|
|
171
|
+
display: flex;
|
|
172
|
+
flex-direction: column;
|
|
173
|
+
gap: var(--space-5);
|
|
174
|
+
max-width: 32rem;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
section {
|
|
178
|
+
display: flex;
|
|
179
|
+
flex-direction: column;
|
|
180
|
+
gap: var(--space-2);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Size is the declared one; only the rhythm is this panel's. */
|
|
184
|
+
.subsection-heading {
|
|
185
|
+
margin: 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
input {
|
|
189
|
+
padding: var(--space-2);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.status {
|
|
193
|
+
color: var(--color-ink-secondary);
|
|
194
|
+
margin: 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* Link renders the anchor, so the scope class cannot land on it — the same
|
|
198
|
+
:global reach ProjectCard and the home page's CTAs use. */
|
|
199
|
+
section :global(a.download) {
|
|
200
|
+
color: var(--color-brand);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.confirm {
|
|
204
|
+
display: flex;
|
|
205
|
+
align-items: baseline;
|
|
206
|
+
gap: var(--space-2);
|
|
207
|
+
font-weight: 400;
|
|
208
|
+
font-size: var(--text-base);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.confirm input {
|
|
212
|
+
padding: 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.danger {
|
|
216
|
+
border: 1px solid var(--series-8);
|
|
217
|
+
border-radius: var(--radius);
|
|
218
|
+
padding: var(--space-3);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.danger h2 {
|
|
222
|
+
color: var(--series-8);
|
|
223
|
+
}
|
|
224
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { FormResultOf, RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
2
|
+
/** The preflighted display-name form. */
|
|
3
|
+
export type UpdateNameFormInstance = RemoteFormInstance<{
|
|
4
|
+
displayName: RemoteField;
|
|
5
|
+
}>;
|
|
6
|
+
/**
|
|
7
|
+
* The newsletter toggle; a success names which way it toggled. The reason
|
|
8
|
+
* is typed open — the host's result union may carry other successes.
|
|
9
|
+
*/
|
|
10
|
+
export type NewsletterToggleFormInstance = RemoteFormInstance<{
|
|
11
|
+
action: RemoteField;
|
|
12
|
+
locale: RemoteField;
|
|
13
|
+
}, FormResultOf<{
|
|
14
|
+
reason: string;
|
|
15
|
+
}>>;
|
|
16
|
+
export type DeleteAccountFormInstance = RemoteFormInstance<{
|
|
17
|
+
confirm: RemoteField;
|
|
18
|
+
locale: RemoteField;
|
|
19
|
+
}>;
|
|
20
|
+
export type LogoutFormInstance = RemoteFormInstance<{
|
|
21
|
+
locale: RemoteField;
|
|
22
|
+
}>;
|
|
23
|
+
interface Props {
|
|
24
|
+
displayName: string;
|
|
25
|
+
email: string | null;
|
|
26
|
+
isSubscribed: boolean;
|
|
27
|
+
/** False when the server lacks the admin key for account deletion. */
|
|
28
|
+
canDelete: boolean;
|
|
29
|
+
/** The remote forms, passed by the host page (updateName preflighted). */
|
|
30
|
+
updateNameForm: UpdateNameFormInstance;
|
|
31
|
+
newsletterToggleForm: NewsletterToggleFormInstance;
|
|
32
|
+
deleteAccountForm: DeleteAccountFormInstance;
|
|
33
|
+
logoutForm: LogoutFormInstance;
|
|
34
|
+
}
|
|
35
|
+
declare const AccountPanel: import("svelte").Component<Props, {}, "">;
|
|
36
|
+
type AccountPanel = ReturnType<typeof AccountPanel>;
|
|
37
|
+
export default AccountPanel;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getUiConfig } from '../../config/context.js';
|
|
3
|
+
import { withNewsletterIntent } from '../../forms/transport.js';
|
|
4
|
+
import type { NewsletterToggleFormInstance } from './AccountPanel.svelte';
|
|
5
|
+
import Button from '../ui/Button.svelte';
|
|
6
|
+
import FormResultSlot from '../ui/FormResultSlot.svelte';
|
|
7
|
+
import Link from '../ui/Link.svelte';
|
|
8
|
+
import LocaleField from '../ui/LocaleField.svelte';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** From the root layout: null when logged out. */
|
|
12
|
+
account?: { displayName: string; newsletterSubscribed: boolean } | null;
|
|
13
|
+
/** The remote form the host layout passes in. */
|
|
14
|
+
newsletterToggleForm: NewsletterToggleFormInstance;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let { account = null, newsletterToggleForm }: Props = $props();
|
|
18
|
+
|
|
19
|
+
const config = getUiConfig();
|
|
20
|
+
const msg = $derived(config.messages);
|
|
21
|
+
|
|
22
|
+
// The newsletter is registered-users-only (the account email is already
|
|
23
|
+
// verified — no app mail needed). Logged out, the band routes to the auth
|
|
24
|
+
// pages carrying the intent as ?newsletter=1 (never an email in a URL).
|
|
25
|
+
const f = $derived(newsletterToggleForm);
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<section class="newsletter" aria-labelledby="newsletter-title">
|
|
29
|
+
<div class="inner band">
|
|
30
|
+
<h2 id="newsletter-title">{msg.newsletter_title()}</h2>
|
|
31
|
+
<p>{msg.newsletter_intro()}</p>
|
|
32
|
+
|
|
33
|
+
{#if account === null}
|
|
34
|
+
<p class="prompt">
|
|
35
|
+
{msg.newsletter_promptLoggedOut()}
|
|
36
|
+
<Link href={withNewsletterIntent('/signup')}>{msg.comments_signupLink()}</Link>
|
|
37
|
+
·
|
|
38
|
+
<Link href={withNewsletterIntent('/login')}>{msg.comments_loginLink()}</Link>
|
|
39
|
+
</p>
|
|
40
|
+
{:else if account.newsletterSubscribed}
|
|
41
|
+
<p class="prompt">
|
|
42
|
+
{msg.newsletter_subscribedNote()}
|
|
43
|
+
<Link href="/account">{msg.account_navLabel()}</Link>
|
|
44
|
+
</p>
|
|
45
|
+
{:else}
|
|
46
|
+
<form {...f}>
|
|
47
|
+
<input {...f.fields.action.as('hidden', 'subscribe')} />
|
|
48
|
+
<LocaleField field={f.fields.locale} />
|
|
49
|
+
<Button type="submit" pending={f.pending}>
|
|
50
|
+
{msg.account_newsletterSubscribe()}
|
|
51
|
+
</Button>
|
|
52
|
+
</form>
|
|
53
|
+
{#if f.result}
|
|
54
|
+
<div class="feedback-slot">
|
|
55
|
+
<FormResultSlot result={f.result} successMessage={msg.account_newsletterSuccessOn()} />
|
|
56
|
+
</div>
|
|
57
|
+
{/if}
|
|
58
|
+
{/if}
|
|
59
|
+
</div>
|
|
60
|
+
</section>
|
|
61
|
+
|
|
62
|
+
<style>
|
|
63
|
+
.newsletter {
|
|
64
|
+
background: var(--color-band-grey);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.inner {
|
|
68
|
+
text-align: center;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
h2 {
|
|
72
|
+
text-transform: uppercase;
|
|
73
|
+
letter-spacing: 0.15em;
|
|
74
|
+
font-size: var(--text-lg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.prompt {
|
|
78
|
+
color: var(--color-ink-secondary);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
form {
|
|
82
|
+
display: flex;
|
|
83
|
+
justify-content: center;
|
|
84
|
+
gap: var(--space-2);
|
|
85
|
+
flex-wrap: wrap;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.feedback-slot {
|
|
89
|
+
margin-top: var(--space-3);
|
|
90
|
+
}
|
|
91
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NewsletterToggleFormInstance } from './AccountPanel.svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** From the root layout: null when logged out. */
|
|
4
|
+
account?: {
|
|
5
|
+
displayName: string;
|
|
6
|
+
newsletterSubscribed: boolean;
|
|
7
|
+
} | null;
|
|
8
|
+
/** The remote form the host layout passes in. */
|
|
9
|
+
newsletterToggleForm: NewsletterToggleFormInstance;
|
|
10
|
+
}
|
|
11
|
+
declare const NewsletterSignup: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type NewsletterSignup = ReturnType<typeof NewsletterSignup>;
|
|
13
|
+
export default NewsletterSignup;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { default as AccountPanel } from './AccountPanel.svelte';
|
|
2
|
+
export type { DeleteAccountFormInstance, LogoutFormInstance, NewsletterToggleFormInstance, UpdateNameFormInstance } from './AccountPanel.svelte';
|
|
3
|
+
export { default as NewsletterSignup } from './NewsletterSignup.svelte';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { getUiConfig } from '../../config/context.js';
|
|
4
|
+
import { hasNewsletterIntent, withNewsletterIntent } from '../../forms/transport.js';
|
|
5
|
+
import PageShell from '../layout/PageShell.svelte';
|
|
6
|
+
import Link from '../ui/Link.svelte';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The /login and /signup chrome: heading, intro, the tab pair that
|
|
10
|
+
* preserves the newsletter intent, and the legal note. Those two pages
|
|
11
|
+
* differ in which forms they compose and in how consent is mirrored into
|
|
12
|
+
* the Google flow — not in any of this.
|
|
13
|
+
*
|
|
14
|
+
* The legal note is here rather than per page because it is the no-JS
|
|
15
|
+
* fallback for the consent copy, and it must appear on both.
|
|
16
|
+
*/
|
|
17
|
+
interface Props {
|
|
18
|
+
/** Browser title. */
|
|
19
|
+
title: string;
|
|
20
|
+
heading: string;
|
|
21
|
+
intro: string;
|
|
22
|
+
/**
|
|
23
|
+
* The URL the tab pair reads. Defaulted to the config's router URL;
|
|
24
|
+
* only tests and stories pass one. The current tab is derived from its
|
|
25
|
+
* canonical pathname, so locale prefixes don't break highlighting.
|
|
26
|
+
*/
|
|
27
|
+
url?: URL;
|
|
28
|
+
/** The email form and the Google button. */
|
|
29
|
+
children: Snippet;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let { title, heading, intro, url = undefined, children }: Props = $props();
|
|
33
|
+
|
|
34
|
+
const config = getUiConfig();
|
|
35
|
+
const msg = $derived(config.messages);
|
|
36
|
+
|
|
37
|
+
const currentUrl = $derived(url ?? config.url() ?? new URL('http://localhost/'));
|
|
38
|
+
const currentPath = $derived(config.canonicalPathname(currentUrl));
|
|
39
|
+
|
|
40
|
+
// Real links, not client-side tabs: mode switching works without JS and each
|
|
41
|
+
// mode keeps its own URL. The newsletter intent survives the switch.
|
|
42
|
+
const tabPath = (path: string): string =>
|
|
43
|
+
hasNewsletterIntent(currentUrl) ? withNewsletterIntent(path) : path;
|
|
44
|
+
|
|
45
|
+
const tabs = $derived([
|
|
46
|
+
{ href: tabPath('/login'), label: msg.login_title(), current: currentPath === '/login' },
|
|
47
|
+
{ href: tabPath('/signup'), label: msg.signup_title(), current: currentPath === '/signup' }
|
|
48
|
+
]);
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<PageShell {title} variant="form">
|
|
52
|
+
<h1>{heading}</h1>
|
|
53
|
+
<p class="intro">{intro}</p>
|
|
54
|
+
<nav class="tabs" aria-label={msg.auth_tabsLabel()}>
|
|
55
|
+
{#each tabs as tab (tab.href)}
|
|
56
|
+
<Link href={tab.href} aria-current={tab.current ? 'page' : undefined}>{tab.label}</Link>
|
|
57
|
+
{/each}
|
|
58
|
+
</nav>
|
|
59
|
+
{@render children()}
|
|
60
|
+
<p class="legal-note">
|
|
61
|
+
{msg.auth_legalNotePre()}
|
|
62
|
+
<Link href="/legal">{msg.signup_termsLink()}</Link>
|
|
63
|
+
</p>
|
|
64
|
+
</PageShell>
|
|
65
|
+
|
|
66
|
+
<style>
|
|
67
|
+
h1 {
|
|
68
|
+
margin: 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.tabs {
|
|
72
|
+
display: flex;
|
|
73
|
+
gap: var(--space-3);
|
|
74
|
+
border-bottom: 1px solid var(--color-hairline);
|
|
75
|
+
margin-bottom: var(--space-4);
|
|
76
|
+
max-width: 24rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.tabs :global(a) {
|
|
80
|
+
text-decoration: none;
|
|
81
|
+
color: var(--color-ink-secondary);
|
|
82
|
+
padding: var(--space-2) 0;
|
|
83
|
+
border-bottom: 2px solid transparent;
|
|
84
|
+
font-weight: 600;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.tabs :global(a:hover) {
|
|
88
|
+
color: var(--color-ink);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.tabs :global(a[aria-current='page']) {
|
|
92
|
+
color: var(--color-ink);
|
|
93
|
+
border-bottom-color: var(--color-brand);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.intro {
|
|
97
|
+
color: var(--color-ink-secondary);
|
|
98
|
+
margin: 0;
|
|
99
|
+
max-width: 40rem;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.legal-note {
|
|
103
|
+
color: var(--color-ink-muted);
|
|
104
|
+
font-size: var(--text-sm);
|
|
105
|
+
margin: 0;
|
|
106
|
+
max-width: 24rem;
|
|
107
|
+
}
|
|
108
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* The /login and /signup chrome: heading, intro, the tab pair that
|
|
4
|
+
* preserves the newsletter intent, and the legal note. Those two pages
|
|
5
|
+
* differ in which forms they compose and in how consent is mirrored into
|
|
6
|
+
* the Google flow — not in any of this.
|
|
7
|
+
*
|
|
8
|
+
* The legal note is here rather than per page because it is the no-JS
|
|
9
|
+
* fallback for the consent copy, and it must appear on both.
|
|
10
|
+
*/
|
|
11
|
+
interface Props {
|
|
12
|
+
/** Browser title. */
|
|
13
|
+
title: string;
|
|
14
|
+
heading: string;
|
|
15
|
+
intro: string;
|
|
16
|
+
/**
|
|
17
|
+
* The URL the tab pair reads. Defaulted to the config's router URL;
|
|
18
|
+
* only tests and stories pass one. The current tab is derived from its
|
|
19
|
+
* canonical pathname, so locale prefixes don't break highlighting.
|
|
20
|
+
*/
|
|
21
|
+
url?: URL;
|
|
22
|
+
/** The email form and the Google button. */
|
|
23
|
+
children: Snippet;
|
|
24
|
+
}
|
|
25
|
+
declare const AuthPageShell: import("svelte").Component<Props, {}, "">;
|
|
26
|
+
type AuthPageShell = ReturnType<typeof AuthPageShell>;
|
|
27
|
+
export default AuthPageShell;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
3
|
+
|
|
4
|
+
/** The Google-login remote form (no preflight: it has no typed fields to check). */
|
|
5
|
+
export type GoogleLoginFormInstance = RemoteFormInstance<{
|
|
6
|
+
newsletter: RemoteField;
|
|
7
|
+
locale: RemoteField;
|
|
8
|
+
}>;
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import { getUiConfig } from '../../config/context.js';
|
|
13
|
+
import Button from '../ui/Button.svelte';
|
|
14
|
+
import FormErrorFeedback from '../ui/FormErrorFeedback.svelte';
|
|
15
|
+
import LocaleField from '../ui/LocaleField.svelte';
|
|
16
|
+
import NewsletterIntentField from '../ui/NewsletterIntentField.svelte';
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
/**
|
|
20
|
+
* Newsletter intent to carry: the band's ?newsletter=1 on /login, or
|
|
21
|
+
* the live state of the signup page's single consent block.
|
|
22
|
+
*/
|
|
23
|
+
newsletterIntent?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Refuse the submission until consent is given (signup page, mirroring
|
|
26
|
+
* the shared terms checkbox). Whether consent is owed *yet* is the
|
|
27
|
+
* signup consent gate's question, not this module's — it reports false
|
|
28
|
+
* before hydration so the no-JS fallback stays usable. /login has no
|
|
29
|
+
* consent block and passes nothing.
|
|
30
|
+
*/
|
|
31
|
+
requireConsent?: boolean;
|
|
32
|
+
/** The remote form the host page passes in. */
|
|
33
|
+
googleLoginForm: GoogleLoginFormInstance;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let { newsletterIntent = false, requireConsent = false, googleLoginForm }: Props = $props();
|
|
37
|
+
|
|
38
|
+
const config = getUiConfig();
|
|
39
|
+
const msg = $derived(config.messages);
|
|
40
|
+
|
|
41
|
+
// One button for both journeys: with public signups open, a first-time
|
|
42
|
+
// Google sign-in creates the account, a returning one just logs in.
|
|
43
|
+
const google = $derived(googleLoginForm);
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<div class="google">
|
|
47
|
+
<p class="divider" role="separator">{msg.login_divider()}</p>
|
|
48
|
+
|
|
49
|
+
<form class="form-stack" {...google}>
|
|
50
|
+
{#if newsletterIntent}
|
|
51
|
+
<!-- Mirrors the single consent block (signup) or the band's intent
|
|
52
|
+
(login); the field module owns the encoding. -->
|
|
53
|
+
<NewsletterIntentField field={google.fields.newsletter} />
|
|
54
|
+
{/if}
|
|
55
|
+
<LocaleField field={google.fields.locale} />
|
|
56
|
+
<div class="actions">
|
|
57
|
+
<Button type="submit" pending={google.pending} disabled={requireConsent}>
|
|
58
|
+
{msg.login_googleSubmit()}
|
|
59
|
+
</Button>
|
|
60
|
+
</div>
|
|
61
|
+
<FormErrorFeedback result={google.result} />
|
|
62
|
+
</form>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<style>
|
|
66
|
+
.google {
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
gap: var(--space-3);
|
|
70
|
+
max-width: 24rem;
|
|
71
|
+
}
|
|
72
|
+
</style>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RemoteField, RemoteFormInstance } from '../../forms/types.js';
|
|
2
|
+
/** The Google-login remote form (no preflight: it has no typed fields to check). */
|
|
3
|
+
export type GoogleLoginFormInstance = RemoteFormInstance<{
|
|
4
|
+
newsletter: RemoteField;
|
|
5
|
+
locale: RemoteField;
|
|
6
|
+
}>;
|
|
7
|
+
interface Props {
|
|
8
|
+
/**
|
|
9
|
+
* Newsletter intent to carry: the band's ?newsletter=1 on /login, or
|
|
10
|
+
* the live state of the signup page's single consent block.
|
|
11
|
+
*/
|
|
12
|
+
newsletterIntent?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Refuse the submission until consent is given (signup page, mirroring
|
|
15
|
+
* the shared terms checkbox). Whether consent is owed *yet* is the
|
|
16
|
+
* signup consent gate's question, not this module's — it reports false
|
|
17
|
+
* before hydration so the no-JS fallback stays usable. /login has no
|
|
18
|
+
* consent block and passes nothing.
|
|
19
|
+
*/
|
|
20
|
+
requireConsent?: boolean;
|
|
21
|
+
/** The remote form the host page passes in. */
|
|
22
|
+
googleLoginForm: GoogleLoginFormInstance;
|
|
23
|
+
}
|
|
24
|
+
declare const GoogleAuthForm: import("svelte").Component<Props, {}, "">;
|
|
25
|
+
type GoogleAuthForm = ReturnType<typeof GoogleAuthForm>;
|
|
26
|
+
export default GoogleAuthForm;
|