mirror your GitHub repos to tangled.org automatically
1<script setup lang="ts">
2import { ref } from 'vue'
3import type { AccountsPayload } from '#server/api/me/accounts.get'
4import type { DashboardPayload, DashboardRepo } from '#server/api/me/dashboard.get'
5
6const installUrl = 'https://github.com/apps/synchub-to/installations/new'
7
8definePageMeta({
9 middleware: ['authenticated'],
10})
11
12useSeoMeta({
13 title: 'Dashboard · synchub.to',
14 description: 'Your synchub.to mirror status.',
15})
16
17const { data, refresh, error } = await useFetch<DashboardPayload>('/api/me/dashboard', { server: false })
18const { data: accountsData, refresh: refreshAccounts } = await useFetch<AccountsPayload>('/api/me/accounts', { server: false })
19
20const switcher = useTemplateRef<HTMLDetailsElement>('switcher')
21const switcherOpen = ref(false)
22
23onMounted(() => {
24 const onPointerDown = (e: PointerEvent) => {
25 if (switcherOpen.value && switcher.value && !switcher.value.contains(e.target as Node)) {
26 switcherOpen.value = false
27 }
28 }
29 document.addEventListener('pointerdown', onPointerDown)
30 onBeforeUnmount(() => document.removeEventListener('pointerdown', onPointerDown))
31})
32
33const activeAccount = computed(() =>
34 accountsData.value?.accounts.find(a => a.active) ?? null,
35)
36const otherAccounts = computed(() =>
37 accountsData.value?.accounts.filter(a => !a.active) ?? [],
38)
39
40function accountLabel(account: { handle: string | null, accountLogin: string | null, did: string }) {
41 if (account.handle) return `@${account.handle}`
42 if (account.accountLogin) return account.accountLogin
43 return account.did
44}
45
46async function switchTo(did: string) {
47 switcherOpen.value = false
48 pendingAction.value = `switch:${did}`
49 try {
50 await postAction('/api/me/switch', { did })
51 await Promise.all([refresh(), refreshAccounts()])
52 flash.value = null
53 }
54 catch {
55 flash.value = 'Error: could not switch account.'
56 }
57 finally {
58 pendingAction.value = null
59 }
60}
61
62const flash = ref<string | null>(null)
63const flashIsError = computed(() => flash.value?.startsWith('Error:') ?? false)
64const pendingAction = ref<string | null>(null)
65
66const heading = useTemplateRef('heading')
67const router = useRouter()
68router.afterEach(() => {
69 nextTick(() => heading.value?.focus({ focusVisible: false }))
70})
71
72function actionKey(scope: string, id: number | string) {
73 return `${scope}:${id}`
74}
75
76async function runAction(key: string, fn: () => Promise<unknown>, successMessage: string) {
77 pendingAction.value = key
78 flash.value = null
79 try {
80 await fn()
81 flash.value = successMessage
82 await refresh()
83 }
84 catch (err: unknown) {
85 const message = (err as { data?: { statusMessage?: string }, message?: string } | null)?.data?.statusMessage
86 ?? (err as Error | null)?.message
87 ?? 'something went wrong'
88 flash.value = `Error: ${message}`
89 }
90 finally {
91 pendingAction.value = null
92 }
93}
94
95// Typed Nitro routes use `:id` placeholders; passing the dynamic path to
96// `$fetch` blows up TS' pattern matcher with "excessive stack depth". Posting
97// a no-body request through native `fetch` and parsing the JSON manually
98// sidesteps that. Same-origin, so cookies are sent automatically.
99async function postAction(url: string, payload?: unknown): Promise<unknown> {
100 const response = await fetch(url, {
101 method: 'POST',
102 credentials: 'same-origin',
103 ...(payload === undefined
104 ? {}
105 : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }),
106 })
107 if (!response.ok) {
108 let statusMessage = response.statusText
109 try {
110 const body: unknown = await response.json()
111 if (body && typeof body === 'object' && 'statusMessage' in body && typeof body.statusMessage === 'string') {
112 statusMessage = body.statusMessage
113 }
114 }
115 catch {
116 // body wasn't json; keep statusText
117 }
118 throw Object.assign(new Error(statusMessage), { statusCode: response.status })
119 }
120 return response.json()
121}
122
123function resync(repo: DashboardRepo) {
124 return runAction(
125 actionKey('resync', repo.id),
126 () => postAction(`/api/repos/${repo.id}/resync`),
127 `queued resync for ${repo.githubFullName}.`,
128 )
129}
130
131function disable(repo: DashboardRepo) {
132 return runAction(
133 actionKey('disable', repo.id),
134 () => postAction(`/api/repos/${repo.id}/disable`),
135 `disabled sync for ${repo.githubFullName}.`,
136 )
137}
138
139function enable(repo: DashboardRepo) {
140 return runAction(
141 actionKey('enable', repo.id),
142 () => postAction(`/api/repos/${repo.id}/enable`),
143 `re-enabled sync for ${repo.githubFullName}.`,
144 )
145}
146
147function rotateKey() {
148 return runAction(
149 'rotate-key',
150 () => postAction('/api/me/rotate-key'),
151 'queued SSH key rotation.',
152 )
153}
154
155async function logout() {
156 switcherOpen.value = false
157 pendingAction.value = 'logout'
158 try {
159 const result = await postAction('/api/me/logout') as { remaining: number }
160 if (result.remaining > 0) {
161 await Promise.all([refresh(), refreshAccounts()])
162 flash.value = null
163 pendingAction.value = null
164 }
165 else {
166 await navigateTo('/')
167 }
168 }
169 catch {
170 pendingAction.value = null
171 flash.value = 'error: could not sign out.'
172 }
173}
174
175function summariseRefs(refs: Record<string, string>): string {
176 const entries = Object.entries(refs)
177 if (entries.length === 0) return '—'
178 if (entries.length === 1) {
179 const [refName, sha] = entries[0]!
180 return `${refName} @ ${sha.slice(0, 7)}`
181 }
182 return `${entries.length} refs`
183}
184
185function fmtDate(iso: string | null): string {
186 if (!iso) return '—'
187 try {
188 return new Date(iso).toLocaleString()
189 }
190 catch {
191 return iso
192 }
193}
194</script>
195
196<template>
197 <div class="page">
198 <a class="skip-link" href="#main">skip to main content</a>
199 <header class="nav-term">
200 <nav class="nav-term__line" aria-label="primary">
201 <span class="prompt" aria-hidden="true">></span>
202 <a class="nav-term__mark" href="/dashboard"><SynchubMark :wordmark="true" :size="18" /></a>
203 <details ref="switcher" class="switcher" :open="switcherOpen" @toggle="switcherOpen = ($event.target as HTMLDetailsElement).open">
204 <summary class="switcher__summary">
205 <span class="switcher__active">{{ activeAccount ? accountLabel(activeAccount) : 'account' }}</span>
206 <span class="switcher__caret" aria-hidden="true">▾</span>
207 </summary>
208 <div class="switcher__menu">
209 <p v-if="activeAccount" class="switcher__current">
210 <span class="switcher__handle">{{ accountLabel(activeAccount) }}</span>
211 <span v-if="activeAccount.accountLogin" class="switcher__org">{{ activeAccount.accountLogin }}</span>
212 </p>
213 <template v-if="otherAccounts.length">
214 <p class="switcher__label">switch to</p>
215 <button
216 v-for="account in otherAccounts"
217 :key="account.did"
218 type="button"
219 class="switcher__item"
220 :disabled="pendingAction !== null"
221 @click="switchTo(account.did)"
222 >
223 <span class="switcher__handle">{{ accountLabel(account) }}</span>
224 <span v-if="account.accountLogin" class="switcher__org">{{ account.accountLogin }}</span>
225 </button>
226 </template>
227 <a class="switcher__item switcher__add" :href="installUrl">+ add account</a>
228 <button
229 type="button"
230 class="switcher__item switcher__logout"
231 :disabled="pendingAction !== null"
232 :aria-busy="pendingAction === 'logout'"
233 @click="logout"
234 >
235 {{ pendingAction === 'logout' ? 'logging out…' : 'log out' }}
236 </button>
237 </div>
238 </details>
239 </nav>
240 </header>
241
242 <div id="main" class="intro">
243 <h1 ref="heading" tabindex="-1" class="heading-target">mirror status</h1>
244 <p class="muted">
245 everything synchub is syncing for your connected GitHub installation.
246 </p>
247 </div>
248
249 <div v-if="error" class="card error" role="alert">
250 <span aria-hidden="true">⚠</span> failed to load dashboard: {{ error.message }}
251 </div>
252
253 <div v-else-if="data">
254 <section class="card">
255 <h2>identity</h2>
256 <dl>
257 <dt>tangled</dt>
258 <dd>
259 <span v-if="data.handle">@{{ data.handle }}</span>
260 <code class="did" :class="{ 'did--secondary': data.handle }">{{ data.did }}</code>
261 </dd>
262 <template v-if="data.installation">
263 <dt>GitHub</dt>
264 <dd>
265 {{ data.installation.accountLogin }}
266 <span class="muted">({{ data.installation.accountType.toLowerCase() }}, install #{{ data.installation.id }})</span>
267 <span v-if="data.installation.suspendedAt" class="badge badge-error">suspended</span>
268 </dd>
269 </template>
270 <template v-else>
271 <dt>GitHub</dt>
272 <dd class="muted">
273 no matching install row
274 </dd>
275 </template>
276 </dl>
277 </section>
278
279 <section class="card">
280 <h2>SSH key</h2>
281 <template v-if="data.sshKey">
282 <p class="small muted">
283 created {{ fmtDate(data.sshKey.createdAt) }}<span v-if="data.sshKey.rotatedAt">, last rotated {{ fmtDate(data.sshKey.rotatedAt) }}</span>.
284 </p>
285 <pre class="pubkey">{{ data.sshKey.publicKey }}</pre>
286 </template>
287 <p v-else class="muted">
288 no key on file yet. it's created automatically on first sign-in.
289 </p>
290 <button
291 type="button"
292 :disabled="pendingAction !== null"
293 :aria-busy="pendingAction === 'rotate-key'"
294 @click="rotateKey"
295 >
296 {{ pendingAction === 'rotate-key' ? 'rotating…' : 'rotate SSH key' }}
297 </button>
298 </section>
299
300 <section class="card">
301 <h2>repositories ({{ data.repos.length }})</h2>
302 <p v-if="data.repos.length === 0" class="muted">
303 no repositories enrolled yet. new installs are backfilled in the background; refresh in a minute.
304 </p>
305 <ul v-else class="repos">
306 <li v-for="repo in data.repos" :key="repo.id" class="repo">
307 <div class="repo__main">
308 <div class="repo__head">
309 <a class="repo__name" :href="`https://github.com/${repo.githubFullName}`" rel="noopener noreferrer">{{ repo.githubFullName }}</a>
310 <span v-if="repo.disabledAt" class="badge badge-disabled">disabled</span>
311 <span v-else class="badge" :class="`badge-${repo.status}`">{{ repo.status }}</span>
312 </div>
313 <dl class="repo__meta">
314 <div class="repo__meta-item">
315 <dt>tangled</dt>
316 <dd v-if="repo.tangledFullName" class="repo__did">{{ repo.tangledFullName }}</dd>
317 <dd v-else class="muted">not yet enrolled</dd>
318 </div>
319 <div class="repo__meta-item">
320 <dt>last synced</dt>
321 <dd>{{ fmtDate(repo.lastSyncedAt) }}</dd>
322 </div>
323 <div class="repo__meta-item">
324 <dt>refs</dt>
325 <dd>{{ summariseRefs(repo.lastSyncedRefs) }}</dd>
326 </div>
327 </dl>
328 <p v-if="repo.lastError" class="repo__error muted small">
329 {{ repo.lastError }}
330 </p>
331 </div>
332 <div class="repo__actions">
333 <button
334 type="button"
335 :disabled="pendingAction !== null"
336 :aria-busy="pendingAction === actionKey('resync', repo.id)"
337 @click="resync(repo)"
338 >
339 {{ pendingAction === actionKey('resync', repo.id) ? 'resyncing…' : 'resync' }}
340 </button>
341 <button
342 v-if="repo.disabledAt"
343 type="button"
344 :disabled="pendingAction !== null"
345 :aria-busy="pendingAction === actionKey('enable', repo.id)"
346 @click="enable(repo)"
347 >
348 {{ pendingAction === actionKey('enable', repo.id) ? 'enabling…' : 'enable' }}
349 </button>
350 <button
351 v-else
352 type="button"
353 :disabled="pendingAction !== null"
354 :aria-busy="pendingAction === actionKey('disable', repo.id)"
355 @click="disable(repo)"
356 >
357 {{ pendingAction === actionKey('disable', repo.id) ? 'disabling…' : 'disable' }}
358 </button>
359 </div>
360 </li>
361 </ul>
362 </section>
363
364 <p
365 v-if="flash"
366 class="flash"
367 :class="{ 'flash--error': flashIsError }"
368 :role="flashIsError ? 'alert' : 'status'"
369 >
370 <span aria-hidden="true">{{ flashIsError ? '⚠' : '✓' }}</span> {{ flash }}
371 </p>
372 </div>
373 </div>
374</template>
375
376<style scoped>
377.page {
378 max-width: 64rem;
379 margin: 0 auto;
380 padding-inline: var(--page-gutter);
381 color: var(--color-ink);
382}
383
384.nav-term {
385 padding: var(--space-md) 0;
386 border-bottom: var(--rule-hair) solid var(--color-rule);
387}
388
389.nav-term__line {
390 display: flex;
391 align-items: center;
392 flex-wrap: wrap;
393 gap: 0.6ch;
394 font-family: var(--font-mono);
395 font-size: var(--text-sm);
396 margin: 0;
397}
398
399.nav-term__line .prompt { color: var(--color-accent); }
400
401.nav-term__mark {
402 display: inline-flex;
403 text-decoration: none;
404 margin-right: 1.2ch;
405}
406
407.switcher {
408 margin-left: auto;
409 position: relative;
410}
411
412.switcher__summary {
413 display: inline-flex;
414 align-items: center;
415 gap: 0.5ch;
416 min-height: 44px;
417 padding: 0.2rem 0.7rem;
418 border: var(--rule-hair) solid var(--color-rule-interactive);
419 border-radius: var(--radius-sm);
420 color: var(--color-muted);
421 font-family: var(--font-mono);
422 font-size: var(--text-sm);
423 white-space: nowrap;
424 cursor: pointer;
425 list-style: none;
426 transition: border-color var(--dur-micro) var(--ease-out), color var(--dur-micro) var(--ease-out);
427}
428
429.switcher__summary::-webkit-details-marker { display: none; }
430.switcher__summary:hover { border-color: var(--color-accent); color: var(--color-ink); }
431.switcher[open] .switcher__summary { border-color: var(--color-accent); color: var(--color-ink); }
432.switcher[open] .switcher__caret { transform: rotate(180deg); }
433
434.switcher__caret {
435 display: inline-block;
436 color: var(--color-neutral);
437 transition: transform var(--dur-micro) var(--ease-out);
438}
439
440.switcher__menu {
441 position: absolute;
442 right: 0;
443 top: calc(100% + var(--space-2xs));
444 z-index: var(--z-overlay);
445 min-width: 13rem;
446 display: flex;
447 flex-direction: column;
448 gap: 1px;
449 padding: var(--space-2xs);
450 background: var(--color-paper-3);
451 border: var(--rule-hair) solid var(--color-rule);
452 border-radius: var(--radius-md);
453}
454
455.switcher__current {
456 display: flex;
457 flex-direction: column;
458 gap: 1px;
459 margin: 0;
460 padding: var(--space-xs) var(--space-sm);
461}
462
463.switcher__label {
464 margin: var(--space-2xs) 0 0;
465 padding: 0 var(--space-sm);
466 font-size: var(--text-xs);
467 letter-spacing: 0.06em;
468 text-transform: uppercase;
469 color: var(--color-neutral);
470}
471
472.switcher__handle {
473 font-family: var(--font-mono);
474 color: var(--color-ink);
475}
476
477.switcher__org {
478 font-size: var(--text-xs);
479 color: var(--color-neutral);
480}
481
482.switcher__item {
483 display: flex;
484 flex-direction: column;
485 gap: 1px;
486 align-items: start;
487 width: 100%;
488 min-height: 44px;
489 justify-content: center;
490 padding: var(--space-xs) var(--space-sm);
491 border: 0;
492 border-radius: var(--radius-sm);
493 background: transparent;
494 color: var(--color-muted);
495 font: inherit;
496 font-size: var(--text-sm);
497 text-align: left;
498 text-decoration: none;
499 white-space: nowrap;
500 cursor: pointer;
501 transition: background-color var(--dur-micro) var(--ease-out);
502}
503
504.switcher__item:hover:not(:disabled) { background: var(--color-paper-2); }
505.switcher__item:disabled { opacity: 0.5; cursor: progress; }
506
507.switcher__add {
508 border-top: var(--rule-hair) solid var(--color-rule);
509 border-radius: 0;
510 margin-top: var(--space-2xs);
511 color: var(--color-accent-dim);
512}
513
514.switcher__logout { color: var(--color-muted); }
515.switcher__logout:hover:not(:disabled) { color: var(--color-error); }
516
517.caret {
518 display: inline-block;
519 width: 1ch;
520 color: var(--color-accent);
521 animation: blink 1.05s steps(2) infinite;
522}
523
524@keyframes blink { 50% { opacity: 0; } }
525@media (prefers-reduced-motion: reduce) { .caret { animation: none; } }
526
527.intro {
528 padding-block: var(--space-xl) var(--space-md);
529}
530
531h1 {
532 font-size: var(--text-xl);
533 margin: 0 0 var(--space-2xs);
534}
535
536.heading-target:focus { outline: none; }
537
538h2 {
539 margin: 0 0 var(--space-md);
540 font-size: var(--text-md);
541}
542
543.muted { color: var(--color-neutral); }
544.small { font-size: var(--text-sm); }
545
546.card {
547 background: var(--color-paper-2);
548 border: var(--rule-hair) solid var(--color-rule);
549 border-radius: var(--radius-md);
550 padding: var(--space-lg);
551 margin: var(--space-md) 0;
552}
553
554.error {
555 border-color: var(--color-error);
556 color: var(--color-error);
557}
558
559dl {
560 display: grid;
561 grid-template-columns: max-content minmax(0, 1fr);
562 align-items: baseline;
563 gap: var(--space-2xs) var(--space-md);
564 margin: 0;
565}
566
567.did {
568 word-break: break-all;
569}
570
571.did--secondary {
572 display: block;
573 margin-top: 2px;
574 font-size: var(--text-xs);
575 color: var(--color-neutral);
576}
577
578dt {
579 font-family: var(--font-mono);
580 font-size: var(--text-sm);
581 color: var(--color-neutral);
582}
583
584dd {
585 margin: 0;
586 overflow-wrap: anywhere;
587 min-width: 0;
588}
589
590code {
591 font-family: var(--font-mono);
592 font-size: var(--text-sm);
593 color: var(--color-ink);
594}
595
596.pubkey {
597 font-family: var(--font-mono);
598 font-size: var(--text-xs);
599 background: var(--color-paper);
600 border: var(--rule-hair) solid var(--color-rule);
601 padding: var(--space-sm);
602 border-radius: var(--radius-sm);
603 overflow-x: auto;
604 white-space: pre-wrap;
605 word-break: break-all;
606 margin: var(--space-xs) 0 var(--space-md);
607 color: var(--color-muted);
608}
609
610.repos {
611 list-style: none;
612 margin: 0;
613 padding: 0;
614}
615
616.repo {
617 display: flex;
618 align-items: start;
619 justify-content: space-between;
620 gap: var(--space-lg);
621 padding-block: var(--space-md);
622 border-top: var(--rule-hair) solid var(--color-rule);
623}
624
625.repo:first-child { border-top: 0; }
626
627.repo__main { min-width: 0; flex: 1; }
628
629.repo__head {
630 display: flex;
631 align-items: center;
632 flex-wrap: wrap;
633 gap: var(--space-xs);
634 margin-bottom: var(--space-xs);
635}
636
637.repo__name {
638 font-family: var(--font-mono);
639 font-size: var(--text-base);
640 color: var(--color-ink);
641 text-decoration: none;
642 border-bottom: var(--rule-hair) solid var(--color-rule);
643 overflow-wrap: anywhere;
644}
645
646.repo__name:hover { color: var(--color-accent); border-color: var(--color-accent); }
647
648.repo__meta {
649 display: flex;
650 flex-wrap: wrap;
651 gap: var(--space-xs) var(--space-lg);
652 margin: 0;
653}
654
655.repo__meta-item {
656 display: flex;
657 flex-direction: column;
658 gap: 1px;
659 min-width: 0;
660}
661
662.repo__meta dt {
663 font-family: var(--font-mono);
664 font-size: var(--text-xs);
665 letter-spacing: 0.06em;
666 text-transform: uppercase;
667 color: var(--color-neutral);
668}
669
670.repo__meta dd {
671 margin: 0;
672 font-size: var(--text-sm);
673 color: var(--color-muted);
674}
675
676.repo__did {
677 font-family: var(--font-mono);
678 word-break: break-all;
679}
680
681.repo__error {
682 margin: var(--space-xs) 0 0;
683 word-break: break-word;
684}
685
686.repo__actions {
687 display: flex;
688 flex-direction: column;
689 gap: var(--space-2xs);
690 flex: none;
691}
692
693.badge {
694 display: inline-block;
695 padding: 0.1rem 0.5rem;
696 border-radius: var(--radius-sm);
697 font-family: var(--font-mono);
698 font-size: var(--text-xs);
699 border: var(--rule-hair) solid var(--color-rule-interactive);
700 color: var(--color-muted);
701 margin-right: var(--space-2xs);
702}
703
704.badge::before {
705 margin-right: 0.4ch;
706}
707
708.badge-active { border-color: var(--color-ok); color: var(--color-ok); }
709.badge-active::before { content: "●"; }
710.badge-pending,
711.badge-enrolling { border-color: var(--color-warn); color: var(--color-warn); }
712.badge-pending::before,
713.badge-enrolling::before { content: "◐"; }
714.badge-error { border-color: var(--color-error); color: var(--color-error); }
715.badge-error::before { content: "⚠"; }
716.badge-disabled { border-color: var(--color-neutral); color: var(--color-neutral); }
717.badge-disabled::before { content: "⏸"; }
718
719button {
720 font-family: var(--font-mono);
721 font-size: var(--text-sm);
722 padding: 0.3rem 0.75rem;
723 border-radius: var(--radius-sm);
724 border: var(--rule-hair) solid var(--color-rule-interactive);
725 background: var(--color-paper-3);
726 color: var(--color-ink);
727 cursor: pointer;
728 white-space: nowrap;
729 transition: border-color var(--dur-micro) var(--ease-out), transform var(--dur-micro) var(--ease-out);
730}
731
732button:hover:not(:disabled) { border-color: var(--color-accent); transform: translateY(-1px); }
733button:active:not(:disabled) { transform: translateY(0); }
734
735button:disabled {
736 opacity: 0.5;
737 cursor: progress;
738}
739
740@media (max-width: 40rem) {
741 .repo {
742 flex-direction: column;
743 gap: var(--space-sm);
744 }
745 .repo__actions {
746 flex-direction: row;
747 flex-wrap: wrap;
748 }
749}
750
751.flash {
752 position: fixed;
753 right: var(--space-md);
754 bottom: var(--space-md);
755 z-index: var(--z-toast);
756 padding: var(--space-xs) var(--space-sm);
757 background: var(--color-paper-3);
758 border: var(--rule-hair) solid var(--color-accent);
759 color: var(--color-ink);
760 border-radius: var(--radius-sm);
761 font-family: var(--font-mono);
762 font-size: var(--text-sm);
763}
764
765.flash--error { border-color: var(--color-error); }
766</style>