@plutocms/core 0.47.1 → 0.48.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/FEATURES.md +15 -4
  3. package/app/components/Pluto/Admin/PlutoAdminBackendBadge.vue +84 -0
  4. package/app/components/Pluto/Admin/PlutoAdminNavbar.vue +7 -0
  5. package/app/components/Pluto/Github/PlutoGithubBackendDetails.vue +180 -0
  6. package/app/components/Pluto/Github/PlutoGithubBuildStatus.vue +17 -4
  7. package/app/components/Pluto/Github/PlutoGithubDeployPage.vue +472 -0
  8. package/app/components/Pluto/Github/PlutoGithubUsersPage.vue +214 -0
  9. package/app/components/Pluto/PlutoHtml.vue +8 -2
  10. package/app/composables/internal/async-component.ts +9 -0
  11. package/app/composables/pluto-extension.ts +4 -0
  12. package/app/composables/pluto-navbar.ts +25 -0
  13. package/app/composables/pluto-registry.ts +1 -0
  14. package/i18n/locales/en.json +164 -2
  15. package/i18n/locales/pt-BR.json +164 -2
  16. package/modules/pluto-github/index.ts +127 -51
  17. package/modules/pluto-github/options.ts +18 -5
  18. package/modules/pluto-github/runtime/app/composables/pluto-github-local-git.ts +50 -0
  19. package/modules/pluto-github/runtime/app/composables/pluto-github-session.ts +8 -1
  20. package/modules/pluto-github/runtime/app/plugins/pluto-github.ts +84 -1
  21. package/modules/pluto-github/runtime/server/adapter.ts +26 -7
  22. package/modules/pluto-github/runtime/server/api/deploy/pages.post.ts +5 -0
  23. package/modules/pluto-github/runtime/server/api/deploy/status.get.ts +5 -0
  24. package/modules/pluto-github/runtime/server/api/local/push.post.ts +5 -0
  25. package/modules/pluto-github/runtime/server/api/local/status.get.ts +5 -0
  26. package/modules/pluto-github/runtime/server/api/users.get.ts +5 -0
  27. package/modules/pluto-github/runtime/server/store/github.ts +4 -1
  28. package/modules/pluto-github/runtime/server/store/local-git.ts +145 -0
  29. package/modules/pluto-github/runtime/server/store/memory.ts +4 -1
  30. package/modules/pluto-github/runtime/server/utils/build-status.ts +16 -7
  31. package/modules/pluto-github/runtime/server/utils/deploy.ts +204 -0
  32. package/modules/pluto-github/runtime/server/utils/github-setup.ts +1 -3
  33. package/modules/pluto-github/runtime/server/utils/github-users.ts +120 -0
  34. package/modules/pluto-github/runtime/server/utils/local-git-routes.ts +95 -0
  35. package/modules/pluto-github/runtime/shared/build-status.ts +26 -0
  36. package/modules/pluto-github/runtime/shared/client-rules.ts +16 -0
  37. package/modules/pluto-github/runtime/shared/deploy.ts +43 -0
  38. package/modules/pluto-github/runtime/shared/pages-workflow.ts +138 -0
  39. package/modules/pluto-github/runtime/shared/repo-users.ts +97 -0
  40. package/package.json +1 -1
  41. package/shared/types/github.d.ts +2 -0
  42. package/shared/types/registry.ts +22 -0
  43. package/shared/utils/html-base.ts +23 -0
@@ -0,0 +1,472 @@
1
+ <script setup lang="ts">
2
+ // The Deploy tab of Settings (`/admin/settings/deploy`). The CMS module adds
3
+ // the route with `extendPages`, and the client plugin adds the tab. It puts
4
+ // the site on a host:
5
+ //
6
+ // - The whole app (the site and the admin): Vercel, Netlify, a Node host. A
7
+ // card opens the import page of the host for this repo.
8
+ // - A static site (the admin stays on the computer): GitHub Pages and
9
+ // Cloudflare Pages. In local mode, "Set up GitHub Pages" writes the
10
+ // workflow, commits it, and turns Pages on with the GitHub CLI.
11
+ //
12
+ // The file lives under `app/` so Tailwind scans it. See the git-cms skill.
13
+ import type {
14
+ PlutoDeployStatus,
15
+ PlutoPagesSetupResult,
16
+ } from '../../../../modules/pluto-github/runtime/shared/deploy'
17
+ import { usePlutoGithubLocalGit } from '../../../../modules/pluto-github/runtime/app/composables/pluto-github-local-git'
18
+
19
+ const DOCS_URL = 'https://pluto.vulmix.dev/docs/start-here/deploy'
20
+
21
+ const { t } = usePlutoI18n()
22
+ const errorText = usePlutoErrorText()
23
+ const localGit = usePlutoGithubLocalGit()
24
+
25
+ useHead({
26
+ title: () =>
27
+ `${t('pluto.settings.tabs.deploy')} - ${t('pluto.pages.settings')}`,
28
+ })
29
+
30
+ const status = ref<PlutoDeployStatus | null>(null)
31
+ const loadError = ref('')
32
+ const loading = ref(true)
33
+
34
+ async function load() {
35
+ loading.value = true
36
+ loadError.value = ''
37
+ try {
38
+ status.value = await $fetch<PlutoDeployStatus>(
39
+ '/api/_pluto/github/deploy/status'
40
+ )
41
+ } catch (error) {
42
+ loadError.value = errorText(error)
43
+ } finally {
44
+ loading.value = false
45
+ }
46
+ }
47
+ onMounted(load)
48
+
49
+ const repoUrl = computed(() =>
50
+ status.value?.repo ? `https://github.com/${status.value.repo}` : ''
51
+ )
52
+
53
+ /** The full-site hosts. Each link opens the import page of the host. */
54
+ const hosts = computed(() => [
55
+ {
56
+ id: 'vercel',
57
+ icon: 'i-lucide-triangle',
58
+ to: repoUrl.value
59
+ ? `https://vercel.com/new/import?s=${encodeURIComponent(repoUrl.value)}`
60
+ : 'https://vercel.com/new',
61
+ },
62
+ {
63
+ id: 'netlify',
64
+ icon: 'i-lucide-diamond',
65
+ to: 'https://app.netlify.com/start',
66
+ },
67
+ {
68
+ id: 'node',
69
+ icon: 'i-lucide-server',
70
+ to: `${DOCS_URL}#a-node-host`,
71
+ },
72
+ ])
73
+
74
+ // --- GitHub Pages ------------------------------------------------------------
75
+
76
+ const settingUp = ref(false)
77
+ const setupError = ref('')
78
+ const setupResult = ref<PlutoPagesSetupResult | null>(null)
79
+
80
+ const pages = computed(() => status.value?.pages)
81
+ const pagesReady = computed(
82
+ () =>
83
+ Boolean(status.value?.workflow.current) &&
84
+ pages.value?.state === 'on' &&
85
+ pages.value.source === 'workflow'
86
+ )
87
+ const pagesUrl = computed(
88
+ () => pages.value?.url || status.value?.pagesUrl || ''
89
+ )
90
+ const pagesSettingsUrl = computed(() =>
91
+ repoUrl.value ? `${repoUrl.value}/settings/pages` : ''
92
+ )
93
+
94
+ /** One line for the state of Pages on GitHub. */
95
+ const pagesLine = computed(() => {
96
+ const current = pages.value
97
+ if (!current) {
98
+ return ''
99
+ }
100
+ if (current.state === 'on') {
101
+ return current.source === 'workflow'
102
+ ? t('pluto.github.deploy.pages.state.on')
103
+ : t('pluto.github.deploy.pages.state.legacy')
104
+ }
105
+ if (current.state === 'off') {
106
+ return t('pluto.github.deploy.pages.state.off')
107
+ }
108
+ return t(`pluto.github.deploy.pages.reason.${current.reason ?? 'error'}`)
109
+ })
110
+
111
+ async function setupPages() {
112
+ settingUp.value = true
113
+ setupError.value = ''
114
+ try {
115
+ const result = await $fetch<PlutoPagesSetupResult>(
116
+ '/api/_pluto/github/deploy/pages',
117
+ { method: 'POST' }
118
+ )
119
+ setupResult.value = result
120
+ status.value = result.status
121
+ // The workflow commit is a new commit to push: the Git badge counts it.
122
+ localGit.refresh()
123
+ } catch (error) {
124
+ setupError.value = errorText(error)
125
+ } finally {
126
+ settingUp.value = false
127
+ }
128
+ }
129
+
130
+ const showWorkflow = ref(false)
131
+ const copied = ref('')
132
+ async function copy(id: string, text: string) {
133
+ try {
134
+ await navigator.clipboard.writeText(text)
135
+ copied.value = id
136
+ setTimeout(() => {
137
+ if (copied.value === id) {
138
+ copied.value = ''
139
+ }
140
+ }, 2000)
141
+ } catch {
142
+ // The text is selectable, so the user can copy it by hand.
143
+ }
144
+ }
145
+
146
+ const cloudflare = {
147
+ command: 'npx nuxt generate',
148
+ output: '.output/public',
149
+ url: 'https://dash.cloudflare.com/?to=/:account/workers-and-pages/create/pages',
150
+ }
151
+ </script>
152
+
153
+ <template>
154
+ <div class="flex flex-col gap-y-8">
155
+ <div>
156
+ <h2 class="text-xl font-semibold">
157
+ {{ t('pluto.settings.tabs.deploy') }}
158
+ </h2>
159
+ <p class="text-muted text-sm">
160
+ {{ t('pluto.github.deploy.intro') }}
161
+ </p>
162
+ </div>
163
+
164
+ <UAlert
165
+ v-if="loadError"
166
+ :title="t('pluto.github.deploy.error')"
167
+ :description="loadError"
168
+ :actions="[
169
+ {
170
+ label: t('pluto.common.tryAgain'),
171
+ color: 'neutral',
172
+ variant: 'outline',
173
+ onClick: load,
174
+ },
175
+ ]"
176
+ color="error"
177
+ icon="i-lucide-circle-x"
178
+ variant="subtle"
179
+ />
180
+
181
+ <!-- The whole app: the site and the admin. -->
182
+ <section class="flex flex-col gap-y-3">
183
+ <div>
184
+ <h3 class="font-semibold">{{ t('pluto.github.deploy.full.title') }}</h3>
185
+ <p class="text-muted text-sm">
186
+ {{ t('pluto.github.deploy.full.description') }}
187
+ </p>
188
+ </div>
189
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
190
+ <UCard v-for="host in hosts" :key="host.id">
191
+ <div class="flex h-full flex-col gap-y-3">
192
+ <div class="flex items-center gap-2">
193
+ <UIcon :name="host.icon" class="size-5" />
194
+ <span class="font-semibold">
195
+ {{ t(`pluto.github.deploy.hosts.${host.id}.name`) }}
196
+ </span>
197
+ </div>
198
+ <p class="text-muted grow text-sm">
199
+ {{ t(`pluto.github.deploy.hosts.${host.id}.description`) }}
200
+ </p>
201
+ <UButton
202
+ :label="t(`pluto.github.deploy.hosts.${host.id}.action`)"
203
+ :to="host.to"
204
+ :color="host.id === 'node' ? 'neutral' : 'primary'"
205
+ :variant="host.id === 'node' ? 'outline' : 'solid'"
206
+ class="self-start"
207
+ target="_blank"
208
+ trailing-icon="i-lucide-external-link"
209
+ />
210
+ </div>
211
+ </UCard>
212
+ </div>
213
+ <p class="text-muted text-sm">
214
+ {{ t('pluto.github.deploy.full.after') }}
215
+ </p>
216
+ </section>
217
+
218
+ <!-- A static site: the admin stays on the computer. -->
219
+ <section class="flex flex-col gap-y-3">
220
+ <div>
221
+ <h3 class="font-semibold">
222
+ {{ t('pluto.github.deploy.static.title') }}
223
+ </h3>
224
+ <p class="text-muted text-sm">
225
+ {{ t('pluto.github.deploy.static.description') }}
226
+ </p>
227
+ </div>
228
+
229
+ <UCard>
230
+ <div class="flex flex-col gap-y-4">
231
+ <div class="flex flex-wrap items-center justify-between gap-3">
232
+ <div class="flex items-center gap-2">
233
+ <UIcon name="i-pluto-github" class="size-5" />
234
+ <span class="font-semibold">
235
+ {{ t('pluto.github.deploy.pages.name') }}
236
+ </span>
237
+ <UBadge
238
+ v-if="pagesReady"
239
+ :label="t('pluto.github.deploy.pages.ready')"
240
+ color="success"
241
+ variant="subtle"
242
+ />
243
+ </div>
244
+ <UButton
245
+ v-if="pagesReady && pagesUrl"
246
+ :label="t('pluto.github.deploy.pages.open')"
247
+ :to="pagesUrl"
248
+ color="neutral"
249
+ target="_blank"
250
+ trailing-icon="i-lucide-external-link"
251
+ variant="outline"
252
+ />
253
+ </div>
254
+
255
+ <p class="text-muted text-sm">
256
+ {{ t('pluto.github.deploy.pages.description') }}
257
+ </p>
258
+
259
+ <USkeleton v-if="loading && !status" class="h-16 w-full" />
260
+
261
+ <!-- Local mode: the checks and the Set up button. -->
262
+ <template v-else-if="status?.local">
263
+ <ul class="flex flex-col gap-y-2 text-sm">
264
+ <li class="flex items-start gap-2">
265
+ <UIcon
266
+ :name="
267
+ status.workflow.current
268
+ ? 'i-lucide-circle-check'
269
+ : 'i-lucide-circle-dashed'
270
+ "
271
+ :class="
272
+ status.workflow.current ? 'text-success' : 'text-muted'
273
+ "
274
+ class="mt-0.5 size-4 shrink-0"
275
+ />
276
+ <span>
277
+ {{
278
+ status.workflow.current
279
+ ? t('pluto.github.deploy.pages.workflow.done')
280
+ : status.workflow.exists
281
+ ? t('pluto.github.deploy.pages.workflow.old')
282
+ : t('pluto.github.deploy.pages.workflow.missing')
283
+ }}
284
+ <code class="text-xs">{{ status.workflow.path }}</code>
285
+ </span>
286
+ </li>
287
+ <li class="flex items-start gap-2">
288
+ <UIcon
289
+ :name="
290
+ pages?.state === 'on' && pages.source === 'workflow'
291
+ ? 'i-lucide-circle-check'
292
+ : 'i-lucide-circle-dashed'
293
+ "
294
+ :class="
295
+ pages?.state === 'on' && pages.source === 'workflow'
296
+ ? 'text-success'
297
+ : 'text-muted'
298
+ "
299
+ class="mt-0.5 size-4 shrink-0"
300
+ />
301
+ <span>{{ pagesLine }}</span>
302
+ </li>
303
+ </ul>
304
+
305
+ <div class="flex flex-wrap items-center gap-2">
306
+ <UButton
307
+ v-if="!pagesReady"
308
+ :label="t('pluto.github.deploy.pages.setup')"
309
+ :loading="settingUp"
310
+ :disabled="!status.repo"
311
+ icon="i-lucide-wand-sparkles"
312
+ @click="setupPages"
313
+ />
314
+ <UButton
315
+ v-if="pages?.state !== 'on' || pages.source !== 'workflow'"
316
+ :label="t('pluto.github.deploy.pages.settings')"
317
+ :to="pagesSettingsUrl"
318
+ :disabled="!pagesSettingsUrl"
319
+ color="neutral"
320
+ target="_blank"
321
+ trailing-icon="i-lucide-external-link"
322
+ variant="outline"
323
+ />
324
+ <UButton
325
+ :label="
326
+ showWorkflow
327
+ ? t('pluto.github.deploy.pages.hideWorkflow')
328
+ : t('pluto.github.deploy.pages.showWorkflow')
329
+ "
330
+ color="neutral"
331
+ variant="ghost"
332
+ @click="showWorkflow = !showWorkflow"
333
+ />
334
+ </div>
335
+
336
+ <UAlert
337
+ v-if="setupError"
338
+ :description="setupError"
339
+ color="error"
340
+ icon="i-lucide-circle-x"
341
+ variant="subtle"
342
+ />
343
+ <UAlert
344
+ v-else-if="setupResult"
345
+ :title="
346
+ setupResult.pagesOn
347
+ ? t('pluto.github.deploy.pages.done.title')
348
+ : t('pluto.github.deploy.pages.partial.title')
349
+ "
350
+ :description="
351
+ setupResult.pagesOn
352
+ ? t('pluto.github.deploy.pages.done.description')
353
+ : t('pluto.github.deploy.pages.partial.description')
354
+ "
355
+ :color="setupResult.pagesOn ? 'success' : 'warning'"
356
+ :icon="
357
+ setupResult.pagesOn
358
+ ? 'i-lucide-circle-check'
359
+ : 'i-lucide-triangle-alert'
360
+ "
361
+ variant="subtle"
362
+ />
363
+ </template>
364
+
365
+ <!-- Not local: the setup by hand. -->
366
+ <template v-else-if="status">
367
+ <ol class="list-decimal space-y-1 pl-5 text-sm">
368
+ <li>{{ t('pluto.github.deploy.pages.manual.step1') }}</li>
369
+ <li>
370
+ {{ t('pluto.github.deploy.pages.manual.step2') }}
371
+ <ULink
372
+ v-if="pagesSettingsUrl"
373
+ :to="pagesSettingsUrl"
374
+ class="text-primary"
375
+ target="_blank"
376
+ >
377
+ {{ t('pluto.github.deploy.pages.settings') }}
378
+ </ULink>
379
+ </li>
380
+ <li>{{ t('pluto.github.deploy.pages.manual.step3') }}</li>
381
+ </ol>
382
+ </template>
383
+
384
+ <div
385
+ v-if="status && (showWorkflow || !status.local)"
386
+ class="flex flex-col gap-y-2"
387
+ >
388
+ <div class="flex items-center justify-between gap-2">
389
+ <code class="text-muted text-xs">{{ status.workflow.path }}</code>
390
+ <UButton
391
+ :label="
392
+ copied === 'workflow'
393
+ ? t('pluto.common.copied')
394
+ : t('pluto.common.copy')
395
+ "
396
+ :icon="
397
+ copied === 'workflow' ? 'i-lucide-check' : 'i-lucide-copy'
398
+ "
399
+ color="neutral"
400
+ size="xs"
401
+ variant="outline"
402
+ @click="copy('workflow', status.workflow.text)"
403
+ />
404
+ </div>
405
+ <pre
406
+ class="bg-elevated max-h-80 overflow-auto rounded-md p-3 text-xs"
407
+ >{{ status.workflow.text }}</pre>
408
+ </div>
409
+
410
+ <p class="text-muted text-xs">
411
+ {{ t('pluto.github.deploy.pages.nodeAdmin') }}
412
+ </p>
413
+ </div>
414
+ </UCard>
415
+
416
+ <UCard>
417
+ <div class="flex flex-col gap-y-3">
418
+ <div class="flex items-center gap-2">
419
+ <UIcon name="i-lucide-cloud" class="size-5" />
420
+ <span class="font-semibold">
421
+ {{ t('pluto.github.deploy.cloudflare.name') }}
422
+ </span>
423
+ </div>
424
+ <p class="text-muted text-sm">
425
+ {{ t('pluto.github.deploy.cloudflare.description') }}
426
+ </p>
427
+ <dl class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2">
428
+ <dt class="text-muted text-sm">
429
+ {{ t('pluto.github.deploy.cloudflare.command') }}
430
+ </dt>
431
+ <dd class="flex items-center gap-2">
432
+ <code class="text-sm">{{ cloudflare.command }}</code>
433
+ <UButton
434
+ :aria-label="t('pluto.common.copy')"
435
+ :icon="
436
+ copied === 'command' ? 'i-lucide-check' : 'i-lucide-copy'
437
+ "
438
+ color="neutral"
439
+ size="xs"
440
+ variant="ghost"
441
+ @click="copy('command', cloudflare.command)"
442
+ />
443
+ </dd>
444
+ <dt class="text-muted text-sm">
445
+ {{ t('pluto.github.deploy.cloudflare.output') }}
446
+ </dt>
447
+ <dd class="flex items-center gap-2">
448
+ <code class="text-sm">{{ cloudflare.output }}</code>
449
+ <UButton
450
+ :aria-label="t('pluto.common.copy')"
451
+ :icon="copied === 'output' ? 'i-lucide-check' : 'i-lucide-copy'"
452
+ color="neutral"
453
+ size="xs"
454
+ variant="ghost"
455
+ @click="copy('output', cloudflare.output)"
456
+ />
457
+ </dd>
458
+ </dl>
459
+ <UButton
460
+ :label="t('pluto.github.deploy.cloudflare.action')"
461
+ :to="cloudflare.url"
462
+ class="self-start"
463
+ color="neutral"
464
+ target="_blank"
465
+ trailing-icon="i-lucide-external-link"
466
+ variant="outline"
467
+ />
468
+ </div>
469
+ </UCard>
470
+ </section>
471
+ </div>
472
+ </template>
@@ -0,0 +1,214 @@
1
+ <script setup lang="ts">
2
+ // The Users page of the Git CMS (`/admin/users`). The CMS module adds the
3
+ // route with `extendPages`, and the client plugin adds the Users item of the
4
+ // menu. It lists the people with write access to the content repo, read only:
5
+ // GitHub owns the access, so the page links to the access settings of the
6
+ // repo. See the git-cms skill.
7
+ import type { TableColumn } from '@nuxt/ui'
8
+ import type {
9
+ PlutoGithubRepoUser,
10
+ PlutoGithubUsersResponse,
11
+ } from '../../../../modules/pluto-github/runtime/shared/repo-users'
12
+
13
+ const { t } = usePlutoI18n()
14
+ const errorText = usePlutoErrorText()
15
+ const auth = usePlutoAuth()
16
+
17
+ useHead({ title: () => t('pluto.github.users.title') })
18
+
19
+ const answer = ref<PlutoGithubUsersResponse | null>(null)
20
+ const status = ref<'loading' | 'ready' | 'error'>('loading')
21
+ const loadError = ref('')
22
+
23
+ async function load() {
24
+ status.value = 'loading'
25
+ try {
26
+ answer.value = await $fetch<PlutoGithubUsersResponse>(
27
+ '/api/_pluto/github/users'
28
+ )
29
+ status.value = 'ready'
30
+ } catch (error) {
31
+ loadError.value = errorText(error)
32
+ status.value = 'error'
33
+ }
34
+ }
35
+ onMounted(load)
36
+
37
+ const repo = computed(
38
+ () =>
39
+ answer.value?.repo ||
40
+ String(
41
+ (useRuntimeConfig().public.plutoGithub as { repo?: string } | undefined)
42
+ ?.repo ?? ''
43
+ )
44
+ )
45
+ const accessUrl = computed(() =>
46
+ repo.value ? `https://github.com/${repo.value}/settings/access` : ''
47
+ )
48
+ const self = computed(() => auth.user.value?.username?.toLowerCase() ?? '')
49
+ const users = computed(() => answer.value?.users ?? [])
50
+ // The i18n key of the reason why the list is empty.
51
+ const REASON_KEYS = {
52
+ 'no-app': 'noApp',
53
+ 'app-error': 'appError',
54
+ 'no-repo': 'noRepo',
55
+ } as const
56
+ const reasonKey = computed(() => REASON_KEYS[answer.value?.reason ?? 'no-repo'])
57
+
58
+ const columns = computed<TableColumn<PlutoGithubRepoUser>[]>(() => [
59
+ { id: 'user', header: t('pluto.github.users.columns.user') },
60
+ { id: 'role', header: t('pluto.github.users.columns.role') },
61
+ { id: 'access', header: t('pluto.github.users.columns.access') },
62
+ ])
63
+ </script>
64
+
65
+ <template>
66
+ <main>
67
+ <AdminView>
68
+ <h1 class="text-3xl font-bold lg:text-4xl">
69
+ {{ t('pluto.github.users.title') }}
70
+ </h1>
71
+
72
+ <div class="flex flex-wrap items-start justify-between gap-4">
73
+ <p class="text-muted max-w-2xl text-sm">
74
+ {{ t('pluto.github.users.description', { repo: repo || 'GitHub' }) }}
75
+ </p>
76
+ <UButton
77
+ v-if="accessUrl"
78
+ :label="t('pluto.github.users.manage')"
79
+ :to="accessUrl"
80
+ color="neutral"
81
+ icon="i-pluto-github"
82
+ target="_blank"
83
+ trailing-icon="i-lucide-external-link"
84
+ variant="outline"
85
+ />
86
+ </div>
87
+
88
+ <UAlert
89
+ v-if="answer?.restricted"
90
+ :description="t('pluto.github.users.restricted')"
91
+ color="info"
92
+ icon="i-lucide-filter"
93
+ variant="subtle"
94
+ />
95
+
96
+ <!-- Loading -->
97
+ <div
98
+ v-if="status === 'loading'"
99
+ class="flex flex-col gap-y-3"
100
+ aria-busy="true"
101
+ >
102
+ <USkeleton v-for="row in 3" :key="row" class="h-12 w-full" />
103
+ </div>
104
+
105
+ <!-- Error -->
106
+ <UAlert
107
+ v-else-if="status === 'error'"
108
+ :title="t('pluto.github.users.error')"
109
+ :description="loadError"
110
+ :actions="[
111
+ {
112
+ label: t('pluto.common.tryAgain'),
113
+ color: 'neutral',
114
+ variant: 'outline',
115
+ onClick: load,
116
+ },
117
+ ]"
118
+ color="error"
119
+ icon="i-lucide-circle-x"
120
+ variant="subtle"
121
+ />
122
+
123
+ <!-- Local mode with no GitHub App or an App that cannot read, or no repo -->
124
+ <UAlert
125
+ v-else-if="answer?.reason"
126
+ :title="t(`pluto.github.users.reason.${reasonKey}.title`)"
127
+ :description="t(`pluto.github.users.reason.${reasonKey}.description`)"
128
+ :actions="
129
+ answer.reason !== 'no-repo'
130
+ ? [
131
+ {
132
+ label: t(`pluto.github.users.reason.${reasonKey}.action`),
133
+ to: '/admin/settings/github',
134
+ color: 'neutral',
135
+ variant: 'outline',
136
+ },
137
+ ]
138
+ : []
139
+ "
140
+ color="neutral"
141
+ icon="i-lucide-info"
142
+ variant="subtle"
143
+ />
144
+
145
+ <UEmpty
146
+ v-else-if="users.length === 0"
147
+ :title="t('pluto.github.users.empty')"
148
+ icon="i-lucide-users"
149
+ />
150
+
151
+ <UTable
152
+ v-else
153
+ :data="users"
154
+ :columns="columns"
155
+ class="border-default rounded-lg border"
156
+ >
157
+ <template #user-cell="{ row }">
158
+ <div class="flex min-w-0 items-center gap-3">
159
+ <UAvatar
160
+ :src="row.original.avatarUrl || undefined"
161
+ :alt="row.original.login"
162
+ size="sm"
163
+ />
164
+ <ULink
165
+ :to="row.original.url"
166
+ class="text-highlighted truncate font-medium"
167
+ target="_blank"
168
+ >
169
+ {{ row.original.login }}
170
+ </ULink>
171
+ <UBadge
172
+ v-if="row.original.login.toLowerCase() === self"
173
+ :label="t('pluto.github.users.you')"
174
+ color="primary"
175
+ size="sm"
176
+ variant="subtle"
177
+ />
178
+ </div>
179
+ </template>
180
+
181
+ <template #role-cell="{ row }">
182
+ <UBadge
183
+ :label="t(`pluto.github.users.roles.${row.original.role}`)"
184
+ :color="row.original.role === 'write' ? 'neutral' : 'primary'"
185
+ variant="subtle"
186
+ />
187
+ </template>
188
+
189
+ <template #access-cell="{ row }">
190
+ <div class="flex flex-col items-start gap-1">
191
+ <span class="text-sm">
192
+ {{
193
+ row.original.role === 'write'
194
+ ? t('pluto.github.users.access.editor')
195
+ : t('pluto.github.users.access.admin')
196
+ }}
197
+ </span>
198
+ <UBadge
199
+ v-if="!row.original.canSignIn"
200
+ :label="t('pluto.github.users.blocked')"
201
+ color="warning"
202
+ size="sm"
203
+ variant="subtle"
204
+ />
205
+ </div>
206
+ </template>
207
+ </UTable>
208
+
209
+ <p class="text-muted text-xs">
210
+ {{ t('pluto.github.users.footnote') }}
211
+ </p>
212
+ </AdminView>
213
+ </main>
214
+ </template>