@cat-factory/app 0.46.11 → 0.46.12
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/app/components/auth/AuthGate.vue +2 -1
- package/app/components/auth/LoginScreen.vue +41 -31
- package/app/components/auth/ResetPasswordScreen.vue +16 -13
- package/app/components/auth/UserMenu.vue +3 -2
- package/app/components/layout/AccountDeploymentSettings.vue +75 -32
- package/app/components/layout/AccountFragmentSettings.vue +2 -3
- package/app/components/layout/AccountTeamSettings.vue +75 -45
- package/app/components/layout/AiProvidersBanner.vue +11 -11
- package/app/components/layout/BoardSwitcher.vue +63 -29
- package/app/components/layout/CommandBar.vue +68 -54
- package/app/components/layout/GitHubPatBanner.vue +13 -6
- package/app/components/layout/IntegrationBackTitle.vue +4 -1
- package/app/components/layout/IntegrationsHub.vue +67 -49
- package/app/components/layout/NotificationsInbox.vue +48 -19
- package/app/components/layout/PersonalSetupModal.vue +26 -16
- package/app/components/layout/ProviderConfigBanner.vue +14 -9
- package/app/components/layout/SideBar.vue +20 -18
- package/app/components/layout/SpendWarningBanner.vue +16 -16
- package/app/components/providers/PersonalSubscriptionSection.vue +1 -2
- package/i18n/locales/en.json +412 -0
- package/i18n/locales/es.json +409 -0
- package/i18n/locales/fr.json +409 -0
- package/i18n/locales/pl.json +409 -0
- package/i18n/locales/uk.json +409 -0
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
3
|
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
4
4
|
import type { AccountRole } from '~/types/domain'
|
|
5
|
+
import type { InvitationStatus } from '@cat-factory/contracts'
|
|
5
6
|
import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
|
|
6
7
|
|
|
7
8
|
// Team settings for an org account: the member roster (with combinable admin /
|
|
@@ -12,13 +13,31 @@ const props = defineProps<{ accountId: string }>()
|
|
|
12
13
|
|
|
13
14
|
const accounts = useAccountsStore()
|
|
14
15
|
const toast = useToast()
|
|
16
|
+
const { t, te } = useI18n()
|
|
15
17
|
const busy = ref(false)
|
|
16
18
|
|
|
17
|
-
const ROLE_ITEMS
|
|
18
|
-
{ label: '
|
|
19
|
-
{ label: '
|
|
20
|
-
{ label: '
|
|
21
|
-
]
|
|
19
|
+
const ROLE_ITEMS = computed<{ label: string; value: AccountRole }[]>(() => [
|
|
20
|
+
{ label: t('layout.accountTeam.roles.admin'), value: 'admin' },
|
|
21
|
+
{ label: t('layout.accountTeam.roles.developer'), value: 'developer' },
|
|
22
|
+
{ label: t('layout.accountTeam.roles.product'), value: 'product' },
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
const EMAIL_PROVIDER_ITEMS = computed(() => [
|
|
26
|
+
{ label: t('layout.accountTeam.email.providers.resend'), value: 'resend' as const },
|
|
27
|
+
{ label: t('layout.accountTeam.email.providers.sendgrid'), value: 'sendgrid' as const },
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
// Invitation status is a closed contract union; map each member to a literal key so the
|
|
31
|
+
// typed-message-key check stays live, with a te()-guarded fallback if a locale omits one.
|
|
32
|
+
const INVITATION_STATUS_KEYS: Record<InvitationStatus, string> = {
|
|
33
|
+
pending: 'layout.accountTeam.invite.status.pending',
|
|
34
|
+
accepted: 'layout.accountTeam.invite.status.accepted',
|
|
35
|
+
revoked: 'layout.accountTeam.invite.status.revoked',
|
|
36
|
+
}
|
|
37
|
+
function invitationStatusLabel(status: InvitationStatus): string {
|
|
38
|
+
const key = INVITATION_STATUS_KEYS[status]
|
|
39
|
+
return te(key) ? t(key) : status
|
|
40
|
+
}
|
|
22
41
|
|
|
23
42
|
/** Whether the signed-in caller is an admin of this account (drives edit affordances). */
|
|
24
43
|
const isAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
|
|
@@ -33,7 +52,7 @@ async function updateMemberRoles(userId: string, roles: AccountRole[]) {
|
|
|
33
52
|
try {
|
|
34
53
|
await accounts.setMemberRoles(props.accountId, userId, roles.length ? roles : ['developer'])
|
|
35
54
|
} catch (e) {
|
|
36
|
-
notifyError('
|
|
55
|
+
notifyError(t('layout.accountTeam.errors.updateRoles'), e)
|
|
37
56
|
}
|
|
38
57
|
}
|
|
39
58
|
|
|
@@ -53,7 +72,7 @@ async function loadAll(accountId: string) {
|
|
|
53
72
|
if (isOrg.value) jobs.push(accounts.loadRoster(accountId))
|
|
54
73
|
await Promise.all(jobs)
|
|
55
74
|
} catch (e) {
|
|
56
|
-
notifyError('
|
|
75
|
+
notifyError(t('layout.accountTeam.errors.loadSettings'), e)
|
|
57
76
|
}
|
|
58
77
|
}
|
|
59
78
|
|
|
@@ -77,9 +96,9 @@ async function createOrganization() {
|
|
|
77
96
|
try {
|
|
78
97
|
await accounts.createOrg(name)
|
|
79
98
|
newOrgName.value = ''
|
|
80
|
-
toast.add({ title: '
|
|
99
|
+
toast.add({ title: t('layout.accountTeam.org.created'), icon: 'i-lucide-check' })
|
|
81
100
|
} catch (e) {
|
|
82
|
-
notifyError('
|
|
101
|
+
notifyError(t('layout.accountTeam.errors.createOrg'), e)
|
|
83
102
|
} finally {
|
|
84
103
|
busy.value = false
|
|
85
104
|
}
|
|
@@ -100,14 +119,14 @@ async function sendInvite() {
|
|
|
100
119
|
)
|
|
101
120
|
inviteEmail.value = ''
|
|
102
121
|
toast.add({
|
|
103
|
-
title: '
|
|
122
|
+
title: t('layout.accountTeam.invite.created'),
|
|
104
123
|
description: acceptUrl
|
|
105
|
-
? '
|
|
106
|
-
: '
|
|
124
|
+
? t('layout.accountTeam.invite.createdEmailed')
|
|
125
|
+
: t('layout.accountTeam.invite.createdShareLink'),
|
|
107
126
|
icon: 'i-lucide-mail-check',
|
|
108
127
|
})
|
|
109
128
|
} catch (e) {
|
|
110
|
-
notifyError('
|
|
129
|
+
notifyError(t('layout.accountTeam.errors.sendInvite'), e)
|
|
111
130
|
} finally {
|
|
112
131
|
busy.value = false
|
|
113
132
|
}
|
|
@@ -117,7 +136,7 @@ async function revoke(id: string) {
|
|
|
117
136
|
try {
|
|
118
137
|
await accounts.revokeInvite(props.accountId, id)
|
|
119
138
|
} catch (e) {
|
|
120
|
-
notifyError('
|
|
139
|
+
notifyError(t('layout.accountTeam.errors.revokeInvite'), e)
|
|
121
140
|
}
|
|
122
141
|
}
|
|
123
142
|
|
|
@@ -136,9 +155,9 @@ async function connectEmail() {
|
|
|
136
155
|
fromAddress: emailFrom.value.trim(),
|
|
137
156
|
})
|
|
138
157
|
emailApiKey.value = ''
|
|
139
|
-
toast.add({ title: '
|
|
158
|
+
toast.add({ title: t('layout.accountTeam.email.connected'), icon: 'i-lucide-check' })
|
|
140
159
|
} catch (e) {
|
|
141
|
-
notifyError('
|
|
160
|
+
notifyError(t('layout.accountTeam.errors.connectEmail'), e)
|
|
142
161
|
} finally {
|
|
143
162
|
busy.value = false
|
|
144
163
|
}
|
|
@@ -149,7 +168,7 @@ async function disconnectEmail() {
|
|
|
149
168
|
try {
|
|
150
169
|
await accounts.disconnectEmail(props.accountId)
|
|
151
170
|
} catch (e) {
|
|
152
|
-
notifyError('
|
|
171
|
+
notifyError(t('layout.accountTeam.errors.disconnectEmail'), e)
|
|
153
172
|
} finally {
|
|
154
173
|
busy.value = false
|
|
155
174
|
}
|
|
@@ -160,22 +179,25 @@ async function disconnectEmail() {
|
|
|
160
179
|
<div class="space-y-6 text-sm">
|
|
161
180
|
<!-- personal-account CTA: members/roles/invitations need an organization -->
|
|
162
181
|
<section v-if="!isOrg" class="rounded-md border border-slate-800 bg-slate-800/40 p-4">
|
|
163
|
-
<h3 class="mb-1 font-semibold text-white">
|
|
182
|
+
<h3 class="mb-1 font-semibold text-white">{{ t('layout.accountTeam.org.ctaTitle') }}</h3>
|
|
164
183
|
<p class="mb-3 text-slate-400">
|
|
165
|
-
|
|
166
|
-
manage their roles — your personal boards stay as they are.
|
|
184
|
+
{{ t('layout.accountTeam.org.ctaBody') }}
|
|
167
185
|
</p>
|
|
168
186
|
<form class="flex gap-2" @submit.prevent="createOrganization">
|
|
169
|
-
<UInput
|
|
187
|
+
<UInput
|
|
188
|
+
v-model="newOrgName"
|
|
189
|
+
:placeholder="t('layout.accountTeam.org.namePlaceholder')"
|
|
190
|
+
class="flex-1"
|
|
191
|
+
/>
|
|
170
192
|
<UButton type="submit" color="primary" :loading="busy" icon="i-lucide-plus">
|
|
171
|
-
|
|
193
|
+
{{ t('layout.accountTeam.org.create') }}
|
|
172
194
|
</UButton>
|
|
173
195
|
</form>
|
|
174
196
|
</section>
|
|
175
197
|
|
|
176
198
|
<!-- members -->
|
|
177
199
|
<section v-if="isOrg">
|
|
178
|
-
<h3 class="mb-2 font-semibold text-white">
|
|
200
|
+
<h3 class="mb-2 font-semibold text-white">{{ t('layout.accountTeam.members.title') }}</h3>
|
|
179
201
|
<ul class="space-y-1">
|
|
180
202
|
<li
|
|
181
203
|
v-for="m in accounts.members"
|
|
@@ -196,13 +218,15 @@ async function disconnectEmail() {
|
|
|
196
218
|
{{ m.roles.join(', ') }}
|
|
197
219
|
</span>
|
|
198
220
|
</li>
|
|
199
|
-
<li v-if="accounts.members.length === 0" class="text-slate-500">
|
|
221
|
+
<li v-if="accounts.members.length === 0" class="text-slate-500">
|
|
222
|
+
{{ t('layout.accountTeam.members.empty') }}
|
|
223
|
+
</li>
|
|
200
224
|
</ul>
|
|
201
225
|
</section>
|
|
202
226
|
|
|
203
227
|
<!-- invitations -->
|
|
204
228
|
<section v-if="isOrg">
|
|
205
|
-
<h3 class="mb-2 font-semibold text-white">
|
|
229
|
+
<h3 class="mb-2 font-semibold text-white">{{ t('layout.accountTeam.invite.title') }}</h3>
|
|
206
230
|
<form class="flex gap-2" @submit.prevent="sendInvite">
|
|
207
231
|
<UInput
|
|
208
232
|
v-model="inviteEmail"
|
|
@@ -211,7 +235,9 @@ async function disconnectEmail() {
|
|
|
211
235
|
class="flex-1"
|
|
212
236
|
/>
|
|
213
237
|
<USelect v-model="inviteRoles" multiple :items="ROLE_ITEMS" class="w-44" />
|
|
214
|
-
<UButton type="submit" color="primary" :loading="busy" icon="i-lucide-send">
|
|
238
|
+
<UButton type="submit" color="primary" :loading="busy" icon="i-lucide-send">
|
|
239
|
+
{{ t('layout.accountTeam.invite.submit') }}
|
|
240
|
+
</UButton>
|
|
215
241
|
</form>
|
|
216
242
|
|
|
217
243
|
<ul v-if="accounts.invitations.length" class="mt-3 space-y-1">
|
|
@@ -222,13 +248,16 @@ async function disconnectEmail() {
|
|
|
222
248
|
>
|
|
223
249
|
<span class="truncate">{{ inv.email }}</span>
|
|
224
250
|
<span class="flex items-center gap-2 text-xs">
|
|
225
|
-
<span class="uppercase tracking-wide text-slate-400">
|
|
251
|
+
<span class="uppercase tracking-wide text-slate-400">
|
|
252
|
+
{{ invitationStatusLabel(inv.status) }}
|
|
253
|
+
</span>
|
|
226
254
|
<UButton
|
|
227
255
|
v-if="inv.status === 'pending'"
|
|
228
256
|
size="xs"
|
|
229
257
|
color="error"
|
|
230
258
|
variant="ghost"
|
|
231
259
|
icon="i-lucide-x"
|
|
260
|
+
:aria-label="t('layout.accountTeam.invite.revoke')"
|
|
232
261
|
@click="revoke(inv.id)"
|
|
233
262
|
/>
|
|
234
263
|
</span>
|
|
@@ -238,48 +267,49 @@ async function disconnectEmail() {
|
|
|
238
267
|
|
|
239
268
|
<!-- email sender -->
|
|
240
269
|
<section>
|
|
241
|
-
<h3 class="mb-2 font-semibold text-white">
|
|
270
|
+
<h3 class="mb-2 font-semibold text-white">{{ t('layout.accountTeam.email.title') }}</h3>
|
|
242
271
|
<p v-if="!accounts.emailConfigured" class="text-slate-500">
|
|
243
|
-
|
|
244
|
-
accept link.
|
|
272
|
+
{{ t('layout.accountTeam.email.notEnabled') }}
|
|
245
273
|
</p>
|
|
246
274
|
<template v-else>
|
|
247
275
|
<div
|
|
248
276
|
v-if="accounts.emailConnection"
|
|
249
277
|
class="flex items-center justify-between rounded-md bg-slate-800/40 px-2 py-1.5"
|
|
250
278
|
>
|
|
251
|
-
<span>
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
279
|
+
<i18n-t keypath="layout.accountTeam.email.connectedAs" tag="span" scope="global">
|
|
280
|
+
<template #provider>
|
|
281
|
+
<strong>{{ accounts.emailConnection.provider }}</strong>
|
|
282
|
+
</template>
|
|
283
|
+
<template #from>{{ accounts.emailConnection.fromAddress }}</template>
|
|
284
|
+
</i18n-t>
|
|
255
285
|
<UButton size="xs" color="error" variant="ghost" :loading="busy" @click="disconnectEmail">
|
|
256
|
-
|
|
286
|
+
{{ t('layout.accountTeam.email.disconnect') }}
|
|
257
287
|
</UButton>
|
|
258
288
|
</div>
|
|
259
289
|
<form v-else class="space-y-2" @submit.prevent="connectEmail">
|
|
260
|
-
<USelect
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
]"
|
|
290
|
+
<USelect v-model="emailProvider" :items="EMAIL_PROVIDER_ITEMS" class="w-full" />
|
|
291
|
+
<UInput
|
|
292
|
+
v-model="emailFrom"
|
|
293
|
+
type="email"
|
|
294
|
+
:placeholder="t('layout.accountTeam.email.fromPlaceholder')"
|
|
266
295
|
class="w-full"
|
|
267
296
|
/>
|
|
268
|
-
<UInput v-model="emailFrom" type="email" placeholder="From address" class="w-full" />
|
|
269
297
|
<UInput
|
|
270
298
|
v-model="emailApiKey"
|
|
271
299
|
type="password"
|
|
272
|
-
placeholder="
|
|
300
|
+
:placeholder="t('layout.accountTeam.email.apiKeyPlaceholder')"
|
|
273
301
|
class="w-full"
|
|
274
302
|
/>
|
|
275
|
-
<UButton type="submit" color="primary" :loading="busy">
|
|
303
|
+
<UButton type="submit" color="primary" :loading="busy">
|
|
304
|
+
{{ t('layout.accountTeam.email.connect') }}
|
|
305
|
+
</UButton>
|
|
276
306
|
</form>
|
|
277
307
|
</template>
|
|
278
308
|
</section>
|
|
279
309
|
|
|
280
310
|
<!-- account-wide provider API keys (admin-only): direct vendors + proxy gateways -->
|
|
281
311
|
<section v-if="isAdmin" class="space-y-6">
|
|
282
|
-
<h3 class="mb-2 font-semibold text-white">
|
|
312
|
+
<h3 class="mb-2 font-semibold text-white">{{ t('layout.accountTeam.apiKeys.title') }}</h3>
|
|
283
313
|
<ProvidersApiKeysSection :account-id="accountId" category="direct" />
|
|
284
314
|
<div class="border-t border-slate-800 pt-6">
|
|
285
315
|
<ProvidersApiKeysSection :account-id="accountId" category="proxy" />
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// live on the ui store, so the auto-open watcher and this banner share one source of truth).
|
|
7
7
|
import { computed } from 'vue'
|
|
8
8
|
|
|
9
|
+
const { t } = useI18n()
|
|
9
10
|
const ui = useUiStore()
|
|
10
11
|
const { ready, hasUsableModel, defaultPresetBroken } = useAiReadiness()
|
|
11
12
|
|
|
@@ -28,20 +29,20 @@ const show = computed(() => showSetup.value || showPreset.value)
|
|
|
28
29
|
<UIcon name="i-lucide-cpu" class="mt-0.5 h-9 w-9 shrink-0 text-amber-400" />
|
|
29
30
|
<div class="min-w-0 flex-1">
|
|
30
31
|
<div class="flex items-start justify-between gap-3">
|
|
31
|
-
<h2 class="text-lg font-semibold text-amber-100">
|
|
32
|
+
<h2 class="text-lg font-semibold text-amber-100">
|
|
33
|
+
{{ t('layout.aiProvidersBanner.setup.title') }}
|
|
34
|
+
</h2>
|
|
32
35
|
<UButton
|
|
33
36
|
color="neutral"
|
|
34
37
|
variant="ghost"
|
|
35
38
|
size="xs"
|
|
36
39
|
icon="i-lucide-x"
|
|
37
|
-
aria-label="
|
|
40
|
+
:aria-label="t('common.close')"
|
|
38
41
|
@click="ui.dismissAiSetup()"
|
|
39
42
|
/>
|
|
40
43
|
</div>
|
|
41
44
|
<p class="mt-1 text-sm text-amber-200/90">
|
|
42
|
-
|
|
43
|
-
with Workers AI enabled — otherwise connect a provider key, subscription, proxy, or
|
|
44
|
-
local runner to continue.
|
|
45
|
+
{{ t('layout.aiProvidersBanner.setup.body') }}
|
|
45
46
|
</p>
|
|
46
47
|
<div class="mt-4">
|
|
47
48
|
<UButton
|
|
@@ -50,7 +51,7 @@ const show = computed(() => showSetup.value || showPreset.value)
|
|
|
50
51
|
icon="i-lucide-settings"
|
|
51
52
|
@click="ui.openAiProviderSetup()"
|
|
52
53
|
>
|
|
53
|
-
|
|
54
|
+
{{ t('layout.aiProvidersBanner.setup.action') }}
|
|
54
55
|
</UButton>
|
|
55
56
|
</div>
|
|
56
57
|
</div>
|
|
@@ -68,20 +69,19 @@ const show = computed(() => showSetup.value || showPreset.value)
|
|
|
68
69
|
<div class="min-w-0 flex-1">
|
|
69
70
|
<div class="flex items-start justify-between gap-3">
|
|
70
71
|
<h2 class="text-sm font-semibold text-amber-100">
|
|
71
|
-
|
|
72
|
+
{{ t('layout.aiProvidersBanner.preset.title') }}
|
|
72
73
|
</h2>
|
|
73
74
|
<UButton
|
|
74
75
|
color="neutral"
|
|
75
76
|
variant="ghost"
|
|
76
77
|
size="xs"
|
|
77
78
|
icon="i-lucide-x"
|
|
78
|
-
aria-label="
|
|
79
|
+
:aria-label="t('common.close')"
|
|
79
80
|
@click="ui.dismissAiPresetMismatch()"
|
|
80
81
|
/>
|
|
81
82
|
</div>
|
|
82
83
|
<p class="mt-1 text-[13px] text-amber-200/90">
|
|
83
|
-
|
|
84
|
-
provider.
|
|
84
|
+
{{ t('layout.aiProvidersBanner.preset.body') }}
|
|
85
85
|
</p>
|
|
86
86
|
<div class="mt-3">
|
|
87
87
|
<UButton
|
|
@@ -91,7 +91,7 @@ const show = computed(() => showSetup.value || showPreset.value)
|
|
|
91
91
|
icon="i-lucide-cpu"
|
|
92
92
|
@click="ui.openAiPresetMismatch()"
|
|
93
93
|
>
|
|
94
|
-
|
|
94
|
+
{{ t('layout.aiProvidersBanner.preset.action') }}
|
|
95
95
|
</UButton>
|
|
96
96
|
</div>
|
|
97
97
|
</div>
|
|
@@ -6,6 +6,8 @@ import type { CloudProvider } from '~/types/domain'
|
|
|
6
6
|
// active board within it, and manages boards (new / rename / delete). The account
|
|
7
7
|
// row is shown only when accounts exist (auth on); in dev it falls back to a plain
|
|
8
8
|
// board switcher over the single unscoped context.
|
|
9
|
+
const { t } = useI18n()
|
|
10
|
+
|
|
9
11
|
const accounts = useAccountsStore()
|
|
10
12
|
const workspace = useWorkspaceStore()
|
|
11
13
|
const ui = useUiStore()
|
|
@@ -23,15 +25,17 @@ function notifyError(title: string, e: unknown) {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
// The cloud provider new services in the active account default to (a service may
|
|
26
|
-
// override it per-frame). `docker` is the local Docker/Podman backend.
|
|
27
|
-
|
|
28
|
+
// override it per-frame). `docker` is the local Docker/Podman backend. The brand
|
|
29
|
+
// names (Cloudflare/AWS/GCP/Azure) are proper nouns kept verbatim; only the
|
|
30
|
+
// `docker`/`custom` labels carry prose, so those are translated.
|
|
31
|
+
const PROVIDERS = computed<{ value: CloudProvider; label: string }[]>(() => [
|
|
28
32
|
{ value: 'cloudflare', label: 'Cloudflare' },
|
|
29
|
-
{ value: 'docker', label: '
|
|
33
|
+
{ value: 'docker', label: t('layout.boardSwitcher.providers.docker') },
|
|
30
34
|
{ value: 'aws', label: 'AWS' },
|
|
31
35
|
{ value: 'gcp', label: 'GCP' },
|
|
32
36
|
{ value: 'azure', label: 'Azure' },
|
|
33
|
-
{ value: 'custom', label: '
|
|
34
|
-
]
|
|
37
|
+
{ value: 'custom', label: t('layout.boardSwitcher.providers.custom') },
|
|
38
|
+
])
|
|
35
39
|
|
|
36
40
|
async function setDefaultProvider(provider: CloudProvider) {
|
|
37
41
|
const id = accounts.activeAccountId
|
|
@@ -39,7 +43,7 @@ async function setDefaultProvider(provider: CloudProvider) {
|
|
|
39
43
|
try {
|
|
40
44
|
await accounts.setDefaultCloudProvider(id, provider)
|
|
41
45
|
} catch (e) {
|
|
42
|
-
notifyError('
|
|
46
|
+
notifyError(t('layout.boardSwitcher.toast.updateProviderFailed'), e)
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -52,12 +56,16 @@ const accountItems = computed<DropdownMenuItem[][]>(() => [
|
|
|
52
56
|
onSelect: () => void selectAccount(a.id),
|
|
53
57
|
})),
|
|
54
58
|
[
|
|
55
|
-
{
|
|
59
|
+
{
|
|
60
|
+
label: t('layout.boardSwitcher.account.newOrg'),
|
|
61
|
+
icon: 'i-lucide-plus',
|
|
62
|
+
onSelect: () => openPrompt('account'),
|
|
63
|
+
},
|
|
56
64
|
// Account settings: the unified per-account panel (team + roles + invitations + email
|
|
57
65
|
// sender + account-tier fragment library). The panel itself handles personal accounts
|
|
58
66
|
// (prompting to create an org for the team tab), so this is not gated.
|
|
59
67
|
{
|
|
60
|
-
label: '
|
|
68
|
+
label: t('layout.boardSwitcher.account.settings'),
|
|
61
69
|
icon: 'i-lucide-settings',
|
|
62
70
|
onSelect: () => ui.openAccountSettings(),
|
|
63
71
|
},
|
|
@@ -65,9 +73,9 @@ const accountItems = computed<DropdownMenuItem[][]>(() => [
|
|
|
65
73
|
...(accounts.activeAccount?.roles?.includes('admin')
|
|
66
74
|
? [
|
|
67
75
|
{
|
|
68
|
-
label: '
|
|
76
|
+
label: t('layout.boardSwitcher.account.defaultProvider'),
|
|
69
77
|
icon: 'i-lucide-cloud',
|
|
70
|
-
children: PROVIDERS.map((p) => ({
|
|
78
|
+
children: PROVIDERS.value.map((p) => ({
|
|
71
79
|
label: p.label,
|
|
72
80
|
trailingIcon:
|
|
73
81
|
(accounts.activeAccount?.defaultCloudProvider ?? 'cloudflare') === p.value
|
|
@@ -89,10 +97,18 @@ const boardItems = computed<DropdownMenuItem[][]>(() => [
|
|
|
89
97
|
onSelect: () => void switchBoard(w.id),
|
|
90
98
|
})),
|
|
91
99
|
[
|
|
92
|
-
{ label: 'New board…', icon: 'i-lucide-plus', onSelect: () => openPrompt('board') },
|
|
93
|
-
{ label: 'Rename board…', icon: 'i-lucide-pencil', onSelect: () => openPrompt('rename') },
|
|
94
100
|
{
|
|
95
|
-
label: '
|
|
101
|
+
label: t('layout.boardSwitcher.board.new'),
|
|
102
|
+
icon: 'i-lucide-plus',
|
|
103
|
+
onSelect: () => openPrompt('board'),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
label: t('layout.boardSwitcher.board.rename'),
|
|
107
|
+
icon: 'i-lucide-pencil',
|
|
108
|
+
onSelect: () => openPrompt('rename'),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
label: t('layout.boardSwitcher.board.delete'),
|
|
96
112
|
icon: 'i-lucide-trash-2',
|
|
97
113
|
color: 'error' as const,
|
|
98
114
|
onSelect: () => void removeBoard(),
|
|
@@ -106,7 +122,7 @@ async function selectAccount(id: string) {
|
|
|
106
122
|
try {
|
|
107
123
|
await workspace.selectAccount(id)
|
|
108
124
|
} catch (e) {
|
|
109
|
-
notifyError('
|
|
125
|
+
notifyError(t('layout.boardSwitcher.toast.switchAccountFailed'), e)
|
|
110
126
|
} finally {
|
|
111
127
|
busy.value = false
|
|
112
128
|
}
|
|
@@ -117,7 +133,7 @@ async function switchBoard(id: string) {
|
|
|
117
133
|
try {
|
|
118
134
|
await workspace.switchTo(id)
|
|
119
135
|
} catch (e) {
|
|
120
|
-
notifyError('
|
|
136
|
+
notifyError(t('layout.boardSwitcher.toast.openBoardFailed'), e)
|
|
121
137
|
} finally {
|
|
122
138
|
busy.value = false
|
|
123
139
|
}
|
|
@@ -129,9 +145,9 @@ async function removeBoard() {
|
|
|
129
145
|
busy.value = true
|
|
130
146
|
try {
|
|
131
147
|
await workspace.remove(id)
|
|
132
|
-
toast.add({ title: '
|
|
148
|
+
toast.add({ title: t('layout.boardSwitcher.toast.boardDeleted'), icon: 'i-lucide-check' })
|
|
133
149
|
} catch (e) {
|
|
134
|
-
notifyError('
|
|
150
|
+
notifyError(t('layout.boardSwitcher.toast.deleteBoardFailed'), e)
|
|
135
151
|
} finally {
|
|
136
152
|
busy.value = false
|
|
137
153
|
}
|
|
@@ -149,11 +165,25 @@ const promptOpen = computed({
|
|
|
149
165
|
if (!v) prompt.value = null
|
|
150
166
|
},
|
|
151
167
|
})
|
|
152
|
-
const promptMeta
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
168
|
+
const promptMeta = computed<
|
|
169
|
+
Record<PromptKind, { title: string; placeholder: string; cta: string }>
|
|
170
|
+
>(() => ({
|
|
171
|
+
account: {
|
|
172
|
+
title: t('layout.boardSwitcher.prompt.account.title'),
|
|
173
|
+
placeholder: t('layout.boardSwitcher.prompt.account.placeholder'),
|
|
174
|
+
cta: t('layout.boardSwitcher.prompt.create'),
|
|
175
|
+
},
|
|
176
|
+
board: {
|
|
177
|
+
title: t('layout.boardSwitcher.prompt.board.title'),
|
|
178
|
+
placeholder: t('layout.boardSwitcher.prompt.board.placeholder'),
|
|
179
|
+
cta: t('layout.boardSwitcher.prompt.create'),
|
|
180
|
+
},
|
|
181
|
+
rename: {
|
|
182
|
+
title: t('layout.boardSwitcher.prompt.rename.title'),
|
|
183
|
+
placeholder: t('layout.boardSwitcher.prompt.rename.placeholder'),
|
|
184
|
+
cta: t('common.save'),
|
|
185
|
+
},
|
|
186
|
+
}))
|
|
157
187
|
/** Whether the current prompt edits a board (so it shows the description field). */
|
|
158
188
|
const promptHasDescription = computed(() => prompt.value === 'board' || prompt.value === 'rename')
|
|
159
189
|
|
|
@@ -184,7 +214,7 @@ async function submitPrompt() {
|
|
|
184
214
|
}
|
|
185
215
|
prompt.value = null
|
|
186
216
|
} catch (e) {
|
|
187
|
-
notifyError('
|
|
217
|
+
notifyError(t('common.actionFailed'), e)
|
|
188
218
|
} finally {
|
|
189
219
|
busy.value = false
|
|
190
220
|
}
|
|
@@ -210,7 +240,7 @@ async function submitPrompt() {
|
|
|
210
240
|
class="h-3.5 w-3.5 shrink-0 text-slate-400"
|
|
211
241
|
/>
|
|
212
242
|
<span class="truncate text-[11px] font-medium uppercase tracking-wide text-slate-400">
|
|
213
|
-
{{ accounts.activeAccount?.name ?? '
|
|
243
|
+
{{ accounts.activeAccount?.name ?? t('layout.boardSwitcher.accountFallback') }}
|
|
214
244
|
</span>
|
|
215
245
|
<UIcon
|
|
216
246
|
name="i-lucide-chevrons-up-down"
|
|
@@ -228,7 +258,7 @@ async function submitPrompt() {
|
|
|
228
258
|
>
|
|
229
259
|
<UIcon name="i-lucide-layout-dashboard" class="h-4 w-4 shrink-0 text-indigo-400" />
|
|
230
260
|
<span class="truncate text-sm font-medium text-white">
|
|
231
|
-
{{ workspace.activeWorkspace?.name ?? '
|
|
261
|
+
{{ workspace.activeWorkspace?.name ?? t('layout.boardSwitcher.boardFallback') }}
|
|
232
262
|
</span>
|
|
233
263
|
<UIcon name="i-lucide-chevron-down" class="ml-auto h-4 w-4 shrink-0 text-slate-500" />
|
|
234
264
|
</button>
|
|
@@ -238,7 +268,7 @@ async function submitPrompt() {
|
|
|
238
268
|
<UModal v-model:open="promptOpen" :title="prompt ? promptMeta[prompt].title : ''">
|
|
239
269
|
<template #body>
|
|
240
270
|
<form class="space-y-3" @submit.prevent="submitPrompt">
|
|
241
|
-
<UFormField label="
|
|
271
|
+
<UFormField :label="t('layout.boardSwitcher.prompt.nameLabel')">
|
|
242
272
|
<UInput
|
|
243
273
|
v-model="promptValue"
|
|
244
274
|
autofocus
|
|
@@ -246,17 +276,21 @@ async function submitPrompt() {
|
|
|
246
276
|
class="w-full"
|
|
247
277
|
/>
|
|
248
278
|
</UFormField>
|
|
249
|
-
<UFormField
|
|
279
|
+
<UFormField
|
|
280
|
+
v-if="promptHasDescription"
|
|
281
|
+
:label="t('layout.boardSwitcher.prompt.descriptionLabel')"
|
|
282
|
+
:hint="t('layout.boardSwitcher.prompt.descriptionHint')"
|
|
283
|
+
>
|
|
250
284
|
<UTextarea
|
|
251
285
|
v-model="promptDescription"
|
|
252
286
|
:rows="3"
|
|
253
|
-
placeholder="
|
|
287
|
+
:placeholder="t('layout.boardSwitcher.prompt.descriptionPlaceholder')"
|
|
254
288
|
class="w-full"
|
|
255
289
|
/>
|
|
256
290
|
</UFormField>
|
|
257
291
|
<div class="flex justify-end gap-2">
|
|
258
292
|
<UButton color="neutral" variant="ghost" :disabled="busy" @click="prompt = null">
|
|
259
|
-
|
|
293
|
+
{{ t('common.cancel') }}
|
|
260
294
|
</UButton>
|
|
261
295
|
<UButton type="submit" color="primary" :loading="busy">
|
|
262
296
|
{{ prompt ? promptMeta[prompt].cta : '' }}
|