@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.
@@ -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: { label: string; value: AccountRole }[] = [
18
- { label: 'Admin', value: 'admin' },
19
- { label: 'Developer', value: 'developer' },
20
- { label: 'Product', value: 'product' },
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('Could not update roles', e)
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('Could not load team settings', e)
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: 'Organization created', icon: 'i-lucide-check' })
99
+ toast.add({ title: t('layout.accountTeam.org.created'), icon: 'i-lucide-check' })
81
100
  } catch (e) {
82
- notifyError('Could not create organization', e)
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: 'Invitation created',
122
+ title: t('layout.accountTeam.invite.created'),
104
123
  description: acceptUrl
105
- ? 'An email was sent (or copy the link from the list below).'
106
- : 'Share the accept link with your teammate.',
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('Could not send invitation', e)
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('Could not revoke invitation', e)
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: 'Email sender connected', icon: 'i-lucide-check' })
158
+ toast.add({ title: t('layout.accountTeam.email.connected'), icon: 'i-lucide-check' })
140
159
  } catch (e) {
141
- notifyError('Could not connect email sender', e)
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('Could not disconnect email sender', e)
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">Invite teammates &amp; manage roles</h3>
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
- Members, roles and invitations live on an organization. Create one to invite teammates and
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 v-model="newOrgName" placeholder="Acme Inc." class="flex-1" />
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
- Create organization
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">Members</h3>
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">No members yet.</li>
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">Invite a teammate</h3>
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">Invite</UButton>
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">{{ inv.status }}</span>
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">Email sender</h3>
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
- Email delivery is not enabled on this deployment. Invitations still produce a shareable
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
- Connected via <strong>{{ accounts.emailConnection.provider }}</strong> as
253
- {{ accounts.emailConnection.fromAddress }}
254
- </span>
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
- Disconnect
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
- v-model="emailProvider"
262
- :items="[
263
- { label: 'Resend', value: 'resend' },
264
- { label: 'SendGrid', value: 'sendgrid' },
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="Provider API key"
300
+ :placeholder="t('layout.accountTeam.email.apiKeyPlaceholder')"
273
301
  class="w-full"
274
302
  />
275
- <UButton type="submit" color="primary" :loading="busy">Connect email sender</UButton>
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">Account API keys</h3>
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">No AI model configured</h2>
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="Dismiss"
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
- Agents need a model to run. AI works out of the box only on a Cloudflare deployment
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
- Configure AI
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
- Default model preset uses unavailable models
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="Dismiss"
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
- Tasks using the default preset would fail. Edit the preset or configure the missing
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
- Review preset
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
- const PROVIDERS: { value: CloudProvider; label: string }[] = [
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: 'Docker (local)' },
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: 'Custom' },
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('Could not update default provider', e)
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
- { label: 'New organization…', icon: 'i-lucide-plus', onSelect: () => openPrompt('account') },
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: 'Account settings',
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: 'Default cloud provider',
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: 'Delete board',
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('Could not switch account', e)
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('Could not open board', e)
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: 'Board deleted', icon: 'i-lucide-check' })
148
+ toast.add({ title: t('layout.boardSwitcher.toast.boardDeleted'), icon: 'i-lucide-check' })
133
149
  } catch (e) {
134
- notifyError('Could not delete board', e)
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: Record<PromptKind, { title: string; placeholder: string; cta: string }> = {
153
- account: { title: 'New organization', placeholder: 'Acme Inc.', cta: 'Create' },
154
- board: { title: 'New board', placeholder: 'Untitled board', cta: 'Create' },
155
- rename: { title: 'Board settings', placeholder: 'Board name', cta: 'Save' },
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('Action failed', e)
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 ?? 'Account' }}
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 ?? 'Board' }}
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="Name">
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 v-if="promptHasDescription" label="Description" hint="Optional">
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="What is this board for?"
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
- Cancel
293
+ {{ t('common.cancel') }}
260
294
  </UButton>
261
295
  <UButton type="submit" color="primary" :loading="busy">
262
296
  {{ prompt ? promptMeta[prompt].cta : '' }}