@ternkit/tern 1.10.0 → 1.11.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.
@@ -0,0 +1,217 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import { tb } from '../runtime/i18n.svelte.js';
4
+ import Alert from './Alert.svelte';
5
+ import Badge from './Badge.svelte';
6
+ import Button from './Button.svelte';
7
+ import Form from './Form.svelte';
8
+ import ToggleGroup from './ToggleGroup.svelte';
9
+ import { money, per, type BillingPlan, type Interval } from './billing.js';
10
+
11
+ type Props = {
12
+ /** From b.Plans(). */
13
+ plans: BillingPlan[];
14
+ /** The plan the owner is on, to mark it and not offer it again. */
15
+ current?: string;
16
+ /** The action that calls b.Checkout with the posted plan and interval. Without it, the buttons link to href. */
17
+ action?: string;
18
+ /** For visitors who aren't signed in: where the buttons go, such as "/signup". */
19
+ href?: string;
20
+ /** Whether plans' trials apply: Checkout gives them to owners who never subscribed. */
21
+ trial?: boolean;
22
+ /** The interval shown first. */
23
+ interval?: Interval;
24
+ /** Words for the plans' limits, by name: { projects: "{n} projects" }. Without one, a limit isn't listed. */
25
+ limitLabels?: Record<string, string>;
26
+ /** The page's form prop, for the action's errors. */
27
+ form?: unknown;
28
+ class?: string;
29
+ };
30
+ let { plans, current, action, href, trial = true, interval = $bindable('month'), limitLabels = {}, form, class: klass = '' }: Props = $props();
31
+
32
+ // The toggle's value: clicking the chosen interval again would clear
33
+ // it, so a cleared one goes back to the interval shown.
34
+ let picked = $state<string | null>(untrack(() => interval));
35
+ $effect.pre(() => {
36
+ if (picked === null) picked = interval;
37
+ else if (picked !== interval) interval = picked as Interval;
38
+ });
39
+ const hasYearly = $derived(plans.some((p) => p.intervals.includes('year')));
40
+ const hasMonthly = $derived(plans.some((p) => p.intervals.includes('month')));
41
+ const message = $derived((form as { message?: string } | null)?.message);
42
+
43
+ // The interval a plan shows: the chosen one if it has it, else its own.
44
+ const shown = (p: BillingPlan): string | null => (p.intervals.includes(interval) ? interval : (p.intervals[0] ?? null));
45
+ // How much a year's price saves over twelve months.
46
+ function saving(p: BillingPlan): number {
47
+ const m = p.amounts?.month, y = p.amounts?.year;
48
+ return m && y && y < m * 12 ? Math.round((1 - y / (m * 12)) * 100) : 0;
49
+ }
50
+ const most = $derived(Math.max(0, ...plans.map(saving)));
51
+ function limits(p: BillingPlan): string[] {
52
+ return Object.entries(p.limits ?? {}).flatMap(([k, n]) => {
53
+ const label = limitLabels[k];
54
+ return label ? [label.replace('{n}', String(n))] : [];
55
+ });
56
+ }
57
+ </script>
58
+
59
+ <div class="tern-pricing {klass}">
60
+ {#if hasYearly && hasMonthly}
61
+ <div class="tern-pricing-toggle">
62
+ <ToggleGroup
63
+ bind:value={picked}
64
+ label={tb('tern.ui.billing.interval')}
65
+ options={[
66
+ { value: 'month', label: tb('tern.ui.billing.monthly') },
67
+ { value: 'year', label: most ? tb('tern.ui.billing.yearlySave', { percent: most }) : tb('tern.ui.billing.yearly') }
68
+ ]}
69
+ />
70
+ </div>
71
+ {/if}
72
+ {#if message}<Alert tone="danger" live>{message}</Alert>{/if}
73
+ <ul class="tern-pricing-plans">
74
+ {#each plans as p (p.id)}
75
+ {@const iv = shown(p)}
76
+ {@const amount = iv ? p.amounts?.[iv] : 0}
77
+ <li class="tern-pricing-plan" class:highlight={p.highlight}>
78
+ <header>
79
+ <h3>{p.name}{#if p.highlight}<Badge tone="accent">{tb('tern.ui.billing.popular')}</Badge>{/if}</h3>
80
+ {#if p.description}<p>{p.description}</p>{/if}
81
+ </header>
82
+ <p class="tern-pricing-price">
83
+ {#if !p.paid}
84
+ <span class="amount">{money(0, p.currency)}</span>
85
+ {:else if amount !== undefined && iv}
86
+ <span class="amount">{money(amount, p.currency)}</span>
87
+ <span class="per">{per(iv)}{p.perSeat ? ` · ${tb('tern.ui.billing.perSeat')}` : ''}</span>
88
+ {/if}
89
+ </p>
90
+ <!-- A note on every plan keeps their buttons in line. -->
91
+ <p class="tern-pricing-note">{p.trialDays && trial ? tb('tern.ui.billing.trial', { days: p.trialDays }) : !p.paid ? tb('tern.ui.billing.noCard') : tb('tern.ui.billing.cancelAnytime')}</p>
92
+ <div class="tern-pricing-cta">
93
+ {#if current === p.id}
94
+ <Button variant="secondary" disabled>{tb('tern.ui.billing.current')}</Button>
95
+ {:else if !p.paid}
96
+ {#if href}<Button href={href} variant="secondary">{tb('tern.ui.billing.startFree')}</Button>{/if}
97
+ {:else if action && iv}
98
+ <Form {action} reset={false}>
99
+ <input type="hidden" name="plan" value={p.id} />
100
+ <input type="hidden" name="interval" value={iv} />
101
+ <Button type="submit" variant={p.highlight ? 'primary' : 'secondary'}>{p.trialDays && trial ? tb('tern.ui.billing.startTrial') : tb('tern.ui.billing.choose', { plan: p.name })}</Button>
102
+ </Form>
103
+ {:else if href}
104
+ <Button href={href} variant={p.highlight ? 'primary' : 'secondary'}>{p.trialDays ? tb('tern.ui.billing.startTrial') : tb('tern.ui.billing.choose', { plan: p.name })}</Button>
105
+ {/if}
106
+ </div>
107
+ {#if limits(p).length || p.features?.length}
108
+ <ul class="tern-pricing-features">
109
+ {#each [...limits(p), ...(p.features ?? [])] as f (f)}
110
+ <li>
111
+ <svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
112
+ {f}
113
+ </li>
114
+ {/each}
115
+ </ul>
116
+ {/if}
117
+ </li>
118
+ {/each}
119
+ </ul>
120
+ </div>
121
+
122
+ <style>
123
+ .tern-pricing {
124
+ display: grid;
125
+ gap: 1.5rem;
126
+ color: var(--tern-fg);
127
+ }
128
+ .tern-pricing-toggle {
129
+ display: flex;
130
+ justify-content: center;
131
+ }
132
+ .tern-pricing-plans {
133
+ display: grid;
134
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
135
+ gap: 1rem;
136
+ margin: 0;
137
+ padding: 0;
138
+ list-style: none;
139
+ }
140
+ .tern-pricing-plan {
141
+ display: flex;
142
+ flex-direction: column;
143
+ gap: 1rem;
144
+ padding: 1.5rem;
145
+ background: var(--tern-surface);
146
+ border: 1px solid var(--tern-border);
147
+ border-radius: calc(var(--tern-radius) + 4px);
148
+ }
149
+ .tern-pricing-plan.highlight {
150
+ border-color: var(--tern-accent);
151
+ box-shadow: 0 0 0 1px var(--tern-accent), var(--tern-shadow);
152
+ }
153
+ header {
154
+ display: grid;
155
+ gap: 0.35rem;
156
+ }
157
+ h3 {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 0.5rem;
161
+ /* As tall with a badge as without. */
162
+ min-height: 1.75rem;
163
+ margin: 0;
164
+ font-size: 1.125rem;
165
+ font-weight: 650;
166
+ }
167
+ header p,
168
+ .tern-pricing-note {
169
+ margin: 0;
170
+ color: var(--tern-muted);
171
+ font-size: 0.875rem;
172
+ }
173
+ .tern-pricing-price {
174
+ display: flex;
175
+ align-items: baseline;
176
+ gap: 0.35rem;
177
+ flex-wrap: wrap;
178
+ margin: 0;
179
+ min-height: 2.75rem;
180
+ }
181
+ .amount {
182
+ font-size: 2.25rem;
183
+ font-weight: 700;
184
+ letter-spacing: -0.03em;
185
+ line-height: 1.2;
186
+ font-variant-numeric: tabular-nums;
187
+ }
188
+ .per {
189
+ color: var(--tern-muted);
190
+ font-size: 0.875rem;
191
+ }
192
+ .tern-pricing-cta {
193
+ display: grid;
194
+ }
195
+ .tern-pricing-cta :global(.tern-form) {
196
+ display: grid;
197
+ }
198
+ .tern-pricing-features {
199
+ display: grid;
200
+ gap: 0.5rem;
201
+ margin: 0;
202
+ padding: 1rem 0 0;
203
+ list-style: none;
204
+ border-top: 1px solid var(--tern-border);
205
+ font-size: 0.875rem;
206
+ }
207
+ .tern-pricing-features li {
208
+ display: flex;
209
+ align-items: flex-start;
210
+ gap: 0.5rem;
211
+ }
212
+ .tern-pricing-features svg {
213
+ flex-shrink: 0;
214
+ margin-top: 0.125rem;
215
+ color: var(--tern-accent);
216
+ }
217
+ </style>
@@ -0,0 +1,23 @@
1
+ import { type BillingPlan, type Interval } from './billing.js';
2
+ type Props = {
3
+ /** From b.Plans(). */
4
+ plans: BillingPlan[];
5
+ /** The plan the owner is on, to mark it and not offer it again. */
6
+ current?: string;
7
+ /** The action that calls b.Checkout with the posted plan and interval. Without it, the buttons link to href. */
8
+ action?: string;
9
+ /** For visitors who aren't signed in: where the buttons go, such as "/signup". */
10
+ href?: string;
11
+ /** Whether plans' trials apply: Checkout gives them to owners who never subscribed. */
12
+ trial?: boolean;
13
+ /** The interval shown first. */
14
+ interval?: Interval;
15
+ /** Words for the plans' limits, by name: { projects: "{n} projects" }. Without one, a limit isn't listed. */
16
+ limitLabels?: Record<string, string>;
17
+ /** The page's form prop, for the action's errors. */
18
+ form?: unknown;
19
+ class?: string;
20
+ };
21
+ declare const PricingTable: import("svelte").Component<Props, {}, "interval">;
22
+ type PricingTable = ReturnType<typeof PricingTable>;
23
+ export default PricingTable;
@@ -0,0 +1,97 @@
1
+ <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
3
+ import AccountSection from './AccountSection.svelte';
4
+ import Alert from './Alert.svelte';
5
+ import Button from './Button.svelte';
6
+ import Field from './Field.svelte';
7
+ import Form from './Form.svelte';
8
+ import Input from './Input.svelte';
9
+ import Select from './Select.svelte';
10
+ import { when } from './accountDates.js';
11
+ import { builtinRoles, formFor, grantable, roleLabel, type RoleOption } from './teams.js';
12
+
13
+ type Invitation = { id: string; email: string; role: string; invitedBy: string; createdAt: string; expiresAt: string };
14
+ type Props = {
15
+ title?: string;
16
+ description?: string;
17
+ level?: 2 | 3 | 4;
18
+ /** From acc.Invitations. */
19
+ invitations: Invitation[];
20
+ /** The signed-in user's role in the team: admins can't invite owners. */
21
+ role: string;
22
+ /** The roles to offer, with labels. The default is owner, admin and member. */
23
+ roles?: RoleOption[];
24
+ /** The role chosen at first. */
25
+ defaultRole?: string;
26
+ /** The action that calls acc.Invite with the posted email and role. */
27
+ inviteAction: string;
28
+ /** The action that calls acc.CancelInvitation with the posted id. */
29
+ cancelAction?: string;
30
+ /** The page's form prop, for the invite form's errors. */
31
+ form?: unknown;
32
+ class?: string;
33
+ };
34
+ let { title, description, level = 2, invitations, role, roles, defaultRole = 'member', inviteAction, cancelAction, form, class: klass = '' }: Props = $props();
35
+
36
+ const all = $derived(roles ?? builtinRoles());
37
+ const offer = $derived(grantable(all, role));
38
+ const mine = $derived(formFor(form, 'invitations'));
39
+ const sent = $derived((form as { invited?: string } | null)?.invited);
40
+ </script>
41
+
42
+ <AccountSection id="tern-invitations-title" title={title ?? tb('tern.ui.team.invite.title')} description={description ?? tb('tern.ui.team.invite.description')} {level} class={klass}>
43
+ <div class="tern-invite">
44
+ <Form action={inviteAction} form={mine}>
45
+ <input type="hidden" name="tern_form" value="invitations" />
46
+ <Field label={tb('tern.ui.team.invite.email')} name="email"><Input type="email" autocomplete="off" spellcheck="false" required /></Field>
47
+ <Field label={tb('tern.ui.team.invite.role')} name="role"><Select options={offer} value={defaultRole} /></Field>
48
+ <div class="tern-invite-submit"><Button type="submit">{tb('tern.ui.team.invite.send')}</Button></div>
49
+ </Form>
50
+ </div>
51
+ {#if sent}<Alert tone="success" live>{tb('tern.ui.team.invite.sent', { email: sent })}</Alert>{/if}
52
+ {#if invitations.length}
53
+ <ul aria-label={tb('tern.ui.team.invite.pending')}>
54
+ {#each invitations as inv (inv.id)}
55
+ <li>
56
+ <div class="tern-account-text">
57
+ <span class="tern-account-name">{inv.email}</span>
58
+ <span class="tern-account-meta">{roleLabel(all, inv.role)} · {tb('tern.ui.team.invite.expires', { when: when(inv.expiresAt) })}</span>
59
+ </div>
60
+ {#if cancelAction}
61
+ <Form action={cancelAction} reset={false}>
62
+ <input type="hidden" name="tern_form" value="invitations" />
63
+ <input type="hidden" name="id" value={inv.id} />
64
+ <Button type="submit" variant="ghost" size="sm" aria-label={tb('tern.ui.team.invite.cancelFor', { email: inv.email })}>{tb('tern.ui.team.invite.cancel')}</Button>
65
+ </Form>
66
+ {/if}
67
+ </li>
68
+ {/each}
69
+ </ul>
70
+ {/if}
71
+ </AccountSection>
72
+
73
+ <style>
74
+ /* The Form is display: contents in an account section: its fields are this grid's. */
75
+ .tern-invite {
76
+ display: grid;
77
+ grid-template-columns: minmax(0, 1fr) minmax(8rem, 12rem) auto;
78
+ align-items: start;
79
+ gap: 0.75rem;
80
+ }
81
+ .tern-invite :global([id^='tern-form-']) {
82
+ grid-column: 1 / -1;
83
+ }
84
+ .tern-invite-submit {
85
+ display: flex;
86
+ /* Lines the button up with the inputs, under their labels. */
87
+ padding-top: 1.625rem;
88
+ }
89
+ @media (max-width: 36rem) {
90
+ .tern-invite {
91
+ grid-template-columns: 1fr;
92
+ }
93
+ .tern-invite-submit {
94
+ padding-top: 0;
95
+ }
96
+ }
97
+ </style>
@@ -0,0 +1,32 @@
1
+ import { type RoleOption } from './teams.js';
2
+ type Invitation = {
3
+ id: string;
4
+ email: string;
5
+ role: string;
6
+ invitedBy: string;
7
+ createdAt: string;
8
+ expiresAt: string;
9
+ };
10
+ type Props = {
11
+ title?: string;
12
+ description?: string;
13
+ level?: 2 | 3 | 4;
14
+ /** From acc.Invitations. */
15
+ invitations: Invitation[];
16
+ /** The signed-in user's role in the team: admins can't invite owners. */
17
+ role: string;
18
+ /** The roles to offer, with labels. The default is owner, admin and member. */
19
+ roles?: RoleOption[];
20
+ /** The role chosen at first. */
21
+ defaultRole?: string;
22
+ /** The action that calls acc.Invite with the posted email and role. */
23
+ inviteAction: string;
24
+ /** The action that calls acc.CancelInvitation with the posted id. */
25
+ cancelAction?: string;
26
+ /** The page's form prop, for the invite form's errors. */
27
+ form?: unknown;
28
+ class?: string;
29
+ };
30
+ declare const TeamInvitations: import("svelte").Component<Props, {}, "">;
31
+ type TeamInvitations = ReturnType<typeof TeamInvitations>;
32
+ export default TeamInvitations;
@@ -0,0 +1,100 @@
1
+ <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
3
+ import AccountSection from './AccountSection.svelte';
4
+ import Alert from './Alert.svelte';
5
+ import Badge from './Badge.svelte';
6
+ import Button from './Button.svelte';
7
+ import Form from './Form.svelte';
8
+ import Select from './Select.svelte';
9
+ import { when } from './accountDates.js';
10
+ import { builtinRoles, grantable, messageFor, roleLabel, type RoleOption } from './teams.js';
11
+
12
+ type Member = { userId: number; email: string; name: string; role: string; joinedAt: string };
13
+ type Props = {
14
+ title?: string;
15
+ description?: string;
16
+ level?: 2 | 3 | 4;
17
+ /** From acc.Members. */
18
+ members: Member[];
19
+ /** The signed-in user's ID. */
20
+ me: number;
21
+ /** The signed-in user's role in the team, from accounts.CurrentTeam. */
22
+ role: string;
23
+ /** The roles to offer, with labels. The default is owner, admin and member; add the app's own TeamRoles. */
24
+ roles?: RoleOption[];
25
+ /** The action that calls acc.SetRole with the posted user and role. */
26
+ roleAction?: string;
27
+ /** The action that calls acc.RemoveMember with the posted user. */
28
+ removeAction?: string;
29
+ /** The action that calls acc.LeaveTeam; then the user's own row has a Leave button. */
30
+ leaveAction?: string;
31
+ /** The page's form prop, to show why a change was refused, such as the last owner leaving. */
32
+ form?: unknown;
33
+ class?: string;
34
+ };
35
+ let { title, description, level = 2, members, me, role, roles, roleAction, removeAction, leaveAction, form, class: klass = '' }: Props = $props();
36
+
37
+ const all = $derived(roles ?? builtinRoles());
38
+ const offer = $derived(grantable(all, role));
39
+ // What the signed-in user may do to a member, as accounts allows it.
40
+ const canChange = (m: Member) => offer.length > 0 && (role === 'owner' || m.role !== 'owner');
41
+ const message = $derived(messageFor(form, 'members'));
42
+ // The last owner can't leave: accounts refuses, so don't offer it.
43
+ const owners = $derived(members.filter((m) => m.role === 'owner').length);
44
+ </script>
45
+
46
+ <AccountSection id="tern-members-title" title={title ?? tb('tern.ui.team.members.title')} description={description ?? tb('tern.ui.team.members.description')} {level} class={klass}>
47
+ {#if message}<Alert tone="danger" live>{message}</Alert>{/if}
48
+ <ul>
49
+ {#each members as m (m.userId)}
50
+ <li>
51
+ <div class="tern-account-text">
52
+ <span class="tern-account-name">{m.name || m.email}{#if m.userId === me}<Badge>{tb('tern.ui.team.you')}</Badge>{/if}</span>
53
+ <span class="tern-account-meta">{m.name ? `${m.email} · ` : ''}{tb('tern.ui.team.joined', { when: when(m.joinedAt) })}</span>
54
+ </div>
55
+ <div class="tern-team-actions">
56
+ {#if roleAction && canChange(m)}
57
+ <Form action={roleAction} reset={false}>
58
+ <input type="hidden" name="tern_form" value="members" /><input type="hidden" name="user" value={m.userId} />
59
+ <Select
60
+ name="role"
61
+ aria-label={tb('tern.ui.team.roleOf', { name: m.name || m.email })}
62
+ value={m.role}
63
+ options={m.role === 'owner' && role !== 'owner' ? all : offer}
64
+ onchange={(e) => (e.currentTarget as HTMLSelectElement).form?.requestSubmit()}
65
+ />
66
+ <noscript><Button type="submit" variant="secondary" size="sm">{tb('tern.ui.team.save')}</Button></noscript>
67
+ </Form>
68
+ {:else}
69
+ <Badge tone={m.role === 'owner' ? 'accent' : 'neutral'}>{roleLabel(all, m.role)}</Badge>
70
+ {/if}
71
+ {#if m.userId === me && leaveAction && !(m.role === 'owner' && owners === 1)}
72
+ <Form action={leaveAction} reset={false}>
73
+ <input type="hidden" name="tern_form" value="members" />
74
+ <Button type="submit" variant="ghost" size="sm">{tb('tern.ui.team.leave')}</Button>
75
+ </Form>
76
+ {:else if m.userId !== me && removeAction && canChange(m)}
77
+ <Form action={removeAction} reset={false}>
78
+ <input type="hidden" name="tern_form" value="members" /><input type="hidden" name="user" value={m.userId} />
79
+ <Button type="submit" variant="ghost" size="sm" aria-label={tb('tern.ui.team.removeName', { name: m.name || m.email })}>{tb('tern.ui.team.remove')}</Button>
80
+ </Form>
81
+ {/if}
82
+ </div>
83
+ </li>
84
+ {/each}
85
+ </ul>
86
+ </AccountSection>
87
+
88
+ <style>
89
+ .tern-team-actions {
90
+ display: flex;
91
+ align-items: center;
92
+ gap: 0.5rem;
93
+ flex-shrink: 0;
94
+ }
95
+ @media (max-width: 30rem) {
96
+ :global(.tern-account) li:has(.tern-team-actions) {
97
+ flex-wrap: wrap;
98
+ }
99
+ }
100
+ </style>
@@ -0,0 +1,33 @@
1
+ import { type RoleOption } from './teams.js';
2
+ type Member = {
3
+ userId: number;
4
+ email: string;
5
+ name: string;
6
+ role: string;
7
+ joinedAt: string;
8
+ };
9
+ type Props = {
10
+ title?: string;
11
+ description?: string;
12
+ level?: 2 | 3 | 4;
13
+ /** From acc.Members. */
14
+ members: Member[];
15
+ /** The signed-in user's ID. */
16
+ me: number;
17
+ /** The signed-in user's role in the team, from accounts.CurrentTeam. */
18
+ role: string;
19
+ /** The roles to offer, with labels. The default is owner, admin and member; add the app's own TeamRoles. */
20
+ roles?: RoleOption[];
21
+ /** The action that calls acc.SetRole with the posted user and role. */
22
+ roleAction?: string;
23
+ /** The action that calls acc.RemoveMember with the posted user. */
24
+ removeAction?: string;
25
+ /** The action that calls acc.LeaveTeam; then the user's own row has a Leave button. */
26
+ leaveAction?: string;
27
+ /** The page's form prop, to show why a change was refused, such as the last owner leaving. */
28
+ form?: unknown;
29
+ class?: string;
30
+ };
31
+ declare const TeamMembers: import("svelte").Component<Props, {}, "">;
32
+ type TeamMembers = ReturnType<typeof TeamMembers>;
33
+ export default TeamMembers;
@@ -0,0 +1,88 @@
1
+ <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
3
+ import AccountSection from './AccountSection.svelte';
4
+ import Alert from './Alert.svelte';
5
+ import AlertDialog from './AlertDialog.svelte';
6
+ import Button from './Button.svelte';
7
+ import Field from './Field.svelte';
8
+ import Form from './Form.svelte';
9
+ import Input from './Input.svelte';
10
+ import { formFor, manages, messageFor } from './teams.js';
11
+
12
+ type Props = {
13
+ title?: string;
14
+ description?: string;
15
+ level?: 2 | 3 | 4;
16
+ /** The current team, from accounts.CurrentTeam. */
17
+ team: { id: number; name: string };
18
+ /** The signed-in user's role in it: admins rename, owners also delete. */
19
+ role: string;
20
+ /** The action that calls acc.RenameTeam with the posted name. */
21
+ renameAction?: string;
22
+ /** The action that calls acc.DeleteTeam. Deleting asks to type the team's name first. */
23
+ deleteAction?: string;
24
+ /** The page's form prop, for the actions' errors. */
25
+ form?: unknown;
26
+ class?: string;
27
+ };
28
+ let { title, description, level = 2, team, role, renameAction, deleteAction, form, class: klass = '' }: Props = $props();
29
+
30
+ let deleteOpen = $state(false);
31
+ let deleteForm = $state<HTMLFormElement>();
32
+ let typed = $state('');
33
+ const message = $derived(messageFor(form, 'team-delete'));
34
+ </script>
35
+
36
+ <AccountSection id="tern-team-settings-title" title={title ?? tb('tern.ui.team.settings.title')} description={description} {level} class={klass}>
37
+ {#if renameAction && manages(role)}
38
+ <Form action={renameAction} form={formFor(form, 'team-rename')} reset={false}>
39
+ <input type="hidden" name="tern_form" value="team-rename" />
40
+ <Field label={tb('tern.ui.team.settings.name')} name="name"><Input value={team.name} required maxlength={100} autocomplete="organization" /></Field>
41
+ <div><Button type="submit" size="sm">{tb('tern.ui.team.settings.rename')}</Button></div>
42
+ </Form>
43
+ {/if}
44
+ {#if deleteAction && role === 'owner'}
45
+ <div class="tern-team-danger">
46
+ <div class="tern-account-text">
47
+ <span class="tern-account-name">{tb('tern.ui.team.settings.delete')}</span>
48
+ <span class="tern-account-meta">{tb('tern.ui.team.settings.deleteHelp')}</span>
49
+ </div>
50
+ <form method="POST" action={deleteAction} bind:this={deleteForm}>
51
+ <input type="hidden" name="tern_form" value="team-delete" />
52
+ <Button type="button" variant="danger" size="sm" onclick={() => ((typed = ''), (deleteOpen = true))}>{tb('tern.ui.team.settings.deleteButton')}</Button>
53
+ </form>
54
+ </div>
55
+ {#if message}<Alert tone="danger" live>{message}</Alert>{/if}
56
+ <AlertDialog
57
+ bind:open={deleteOpen}
58
+ title={tb('tern.ui.team.settings.deleteConfirm', { team: team.name })}
59
+ description={tb('tern.ui.team.settings.deleteWarning')}
60
+ confirmLabel={tb('tern.ui.team.settings.deleteButton')}
61
+ danger
62
+ confirmDisabled={typed.trim() !== team.name.trim()}
63
+ onconfirm={() => deleteForm?.requestSubmit()}
64
+ >
65
+ <Field label={tb('tern.ui.team.settings.typeName', { team: team.name })} name="confirm-team"><Input bind:value={typed} autocomplete="off" spellcheck="false" /></Field>
66
+ </AlertDialog>
67
+ {/if}
68
+ </AccountSection>
69
+
70
+ <style>
71
+ .tern-team-danger {
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: space-between;
75
+ gap: 1rem;
76
+ padding: 0.75rem 1rem;
77
+ border: 1px solid var(--tern-danger-soft);
78
+ border-radius: var(--tern-radius);
79
+ }
80
+ .tern-team-danger form {
81
+ flex-shrink: 0;
82
+ }
83
+ @media (max-width: 30rem) {
84
+ .tern-team-danger {
85
+ flex-wrap: wrap;
86
+ }
87
+ }
88
+ </style>
@@ -0,0 +1,22 @@
1
+ type Props = {
2
+ title?: string;
3
+ description?: string;
4
+ level?: 2 | 3 | 4;
5
+ /** The current team, from accounts.CurrentTeam. */
6
+ team: {
7
+ id: number;
8
+ name: string;
9
+ };
10
+ /** The signed-in user's role in it: admins rename, owners also delete. */
11
+ role: string;
12
+ /** The action that calls acc.RenameTeam with the posted name. */
13
+ renameAction?: string;
14
+ /** The action that calls acc.DeleteTeam. Deleting asks to type the team's name first. */
15
+ deleteAction?: string;
16
+ /** The page's form prop, for the actions' errors. */
17
+ form?: unknown;
18
+ class?: string;
19
+ };
20
+ declare const TeamSettings: import("svelte").Component<Props, {}, "">;
21
+ type TeamSettings = ReturnType<typeof TeamSettings>;
22
+ export default TeamSettings;
@@ -0,0 +1,58 @@
1
+ <script lang="ts">
2
+ import { tb } from '../runtime/i18n.svelte.js';
3
+ import Menu from './Menu.svelte';
4
+ import type { MenuItem } from './menu.js';
5
+
6
+ type Membership = { team: { id: number; name: string }; role: string };
7
+ type Props = {
8
+ /** From acc.Teams. */
9
+ teams: Membership[];
10
+ /** The current team's ID, from accounts.CurrentTeam. */
11
+ current: number;
12
+ /** The action that calls acc.SwitchTeam with the posted id. */
13
+ action: string;
14
+ /** A link to make a new team, such as "/teams/new". */
15
+ newHref?: string;
16
+ /** Opens to the start or end edge of the button. */
17
+ align?: 'start' | 'end';
18
+ class?: string;
19
+ };
20
+ let { teams, current, action, newHref, align = 'start', class: klass = '' }: Props = $props();
21
+
22
+ let form = $state<HTMLFormElement>();
23
+ let chosen = $state('');
24
+ const name = $derived(teams.find((m) => m.team.id === current)?.team.name ?? tb('tern.ui.team.switcher.none'));
25
+ const items = $derived<MenuItem[]>([
26
+ { heading: tb('tern.ui.team.switcher.heading') },
27
+ ...teams.map((m) => ({
28
+ label: m.team.name,
29
+ type: 'radio' as const,
30
+ checked: m.team.id === current,
31
+ onselect: () => {
32
+ if (m.team.id === current) return;
33
+ chosen = String(m.team.id);
34
+ // After the hidden input has the value.
35
+ queueMicrotask(() => form?.requestSubmit());
36
+ }
37
+ })),
38
+ ...(newHref ? [{ separator: true }, { label: tb('tern.ui.team.switcher.new'), href: newHref }] : [])
39
+ ]);
40
+ </script>
41
+
42
+ <!-- Posts the chosen team to the switch action; the page reloads its data. -->
43
+ <form method="POST" {action} bind:this={form} hidden>
44
+ <input type="hidden" name="id" value={chosen} />
45
+ </form>
46
+ <Menu {items} {align} variant="ghost" class="tern-team-switcher {klass}">
47
+ <span class="tern-team-switcher-name">{name}</span>
48
+ </Menu>
49
+
50
+ <style>
51
+ .tern-team-switcher-name {
52
+ max-width: 14rem;
53
+ overflow: hidden;
54
+ text-overflow: ellipsis;
55
+ white-space: nowrap;
56
+ font-weight: 600;
57
+ }
58
+ </style>