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 <a class="config-link" :href="`https://github.com/settings/installations/${data.installation.id}`" target="_blank" rel="noopener noreferrer">configure on GitHub ↗</a>
269 </dd>
270 </template>
271 <template v-else>
272 <dt>GitHub</dt>
273 <dd class="muted">
274 no matching install row
275 </dd>
276 </template>
277 </dl>
278 </section>
279
280 <section class="card">
281 <h2>SSH key</h2>
282 <template v-if="data.sshKey">
283 <p class="small muted">
284 created {{ fmtDate(data.sshKey.createdAt) }}<span v-if="data.sshKey.rotatedAt">, last rotated {{ fmtDate(data.sshKey.rotatedAt) }}</span>.
285 </p>
286 <pre class="pubkey">{{ data.sshKey.publicKey }}</pre>
287 </template>
288 <p v-else class="muted">
289 no key on file yet. it's created automatically on first sign-in.
290 </p>
291 <button
292 type="button"
293 :disabled="pendingAction !== null"
294 :aria-busy="pendingAction === 'rotate-key'"
295 @click="rotateKey"
296 >
297 {{ pendingAction === 'rotate-key' ? 'rotating…' : 'rotate SSH key' }}
298 </button>
299 </section>
300
301 <section class="card">
302 <h2>repositories ({{ data.repos.length }})</h2>
303 <p v-if="data.repos.length === 0" class="muted">
304 no repositories enrolled yet. new installs are backfilled in the background; refresh in a minute.
305 </p>
306 <ul v-else class="repos">
307 <li v-for="repo in data.repos" :key="repo.id" class="repo">
308 <div class="repo__main">
309 <div class="repo__head">
310 <a class="repo__name" :href="`https://github.com/${repo.githubFullName}`" rel="noopener noreferrer">{{ repo.githubFullName }}</a>
311 <span v-if="repo.disabledAt" class="badge badge-disabled">disabled</span>
312 <span v-else class="badge" :class="`badge-${repo.status}`">{{ repo.status }}</span>
313 </div>
314 <dl class="repo__meta">
315 <div class="repo__meta-item">
316 <dt>tangled</dt>
317 <dd v-if="repo.tangledFullName" class="repo__did">{{ repo.tangledFullName }}</dd>
318 <dd v-else class="muted">not yet enrolled</dd>
319 </div>
320 <div class="repo__meta-item">
321 <dt>last synced</dt>
322 <dd>{{ fmtDate(repo.lastSyncedAt) }}</dd>
323 </div>
324 <div class="repo__meta-item">
325 <dt>refs</dt>
326 <dd>{{ summariseRefs(repo.lastSyncedRefs) }}</dd>
327 </div>
328 </dl>
329 <p v-if="repo.lastError" class="repo__error muted small">
330 {{ repo.lastError }}
331 </p>
332 </div>
333 <div class="repo__actions">
334 <button
335 type="button"
336 :disabled="pendingAction !== null"
337 :aria-busy="pendingAction === actionKey('resync', repo.id)"
338 @click="resync(repo)"
339 >
340 {{ pendingAction === actionKey('resync', repo.id) ? 'resyncing…' : 'resync' }}
341 </button>
342 <button
343 v-if="repo.disabledAt"
344 type="button"
345 :disabled="pendingAction !== null"
346 :aria-busy="pendingAction === actionKey('enable', repo.id)"
347 @click="enable(repo)"
348 >
349 {{ pendingAction === actionKey('enable', repo.id) ? 'enabling…' : 'enable' }}
350 </button>
351 <button
352 v-else
353 type="button"
354 :disabled="pendingAction !== null"
355 :aria-busy="pendingAction === actionKey('disable', repo.id)"
356 @click="disable(repo)"
357 >
358 {{ pendingAction === actionKey('disable', repo.id) ? 'disabling…' : 'disable' }}
359 </button>
360 </div>
361 </li>
362 </ul>
363 </section>
364
365 <p
366 v-if="flash"
367 class="flash"
368 :class="{ 'flash--error': flashIsError }"
369 :role="flashIsError ? 'alert' : 'status'"
370 >
371 <span aria-hidden="true">{{ flashIsError ? '⚠' : '✓' }}</span> {{ flash }}
372 </p>
373 </div>
374 </div>
375</template>
376
377<style scoped>
378.page {
379 max-width: 64rem;
380 margin: 0 auto;
381 padding-inline: var(--page-gutter);
382 color: var(--color-ink);
383}
384
385.nav-term {
386 padding: var(--space-md) 0;
387 border-bottom: var(--rule-hair) solid var(--color-rule);
388}
389
390.nav-term__line {
391 display: flex;
392 align-items: center;
393 flex-wrap: wrap;
394 gap: 0.6ch;
395 font-family: var(--font-mono);
396 font-size: var(--text-sm);
397 margin: 0;
398}
399
400.nav-term__line .prompt { color: var(--color-accent); }
401
402.nav-term__mark {
403 display: inline-flex;
404 text-decoration: none;
405 margin-right: 1.2ch;
406}
407
408.switcher {
409 margin-left: auto;
410 position: relative;
411}
412
413.switcher__summary {
414 display: inline-flex;
415 align-items: center;
416 gap: 0.5ch;
417 min-height: 44px;
418 padding: 0.2rem 0.7rem;
419 border: var(--rule-hair) solid var(--color-rule-interactive);
420 border-radius: var(--radius-sm);
421 color: var(--color-muted);
422 font-family: var(--font-mono);
423 font-size: var(--text-sm);
424 white-space: nowrap;
425 cursor: pointer;
426 list-style: none;
427 transition: border-color var(--dur-micro) var(--ease-out), color var(--dur-micro) var(--ease-out);
428}
429
430.switcher__summary::-webkit-details-marker { display: none; }
431.switcher__summary:hover { border-color: var(--color-accent); color: var(--color-ink); }
432.switcher[open] .switcher__summary { border-color: var(--color-accent); color: var(--color-ink); }
433.switcher[open] .switcher__caret { transform: rotate(180deg); }
434
435.switcher__caret {
436 display: inline-block;
437 color: var(--color-neutral);
438 transition: transform var(--dur-micro) var(--ease-out);
439}
440
441.switcher__menu {
442 position: absolute;
443 right: 0;
444 top: calc(100% + var(--space-2xs));
445 z-index: var(--z-overlay);
446 min-width: 13rem;
447 display: flex;
448 flex-direction: column;
449 gap: 1px;
450 padding: var(--space-2xs);
451 background: var(--color-paper-3);
452 border: var(--rule-hair) solid var(--color-rule);
453 border-radius: var(--radius-md);
454}
455
456.switcher__current {
457 display: flex;
458 flex-direction: column;
459 gap: 1px;
460 margin: 0;
461 padding: var(--space-xs) var(--space-sm);
462}
463
464.switcher__label {
465 margin: var(--space-2xs) 0 0;
466 padding: 0 var(--space-sm);
467 font-size: var(--text-xs);
468 letter-spacing: 0.06em;
469 text-transform: uppercase;
470 color: var(--color-neutral);
471}
472
473.switcher__handle {
474 font-family: var(--font-mono);
475 color: var(--color-ink);
476}
477
478.switcher__org {
479 font-size: var(--text-xs);
480 color: var(--color-neutral);
481}
482
483.switcher__item {
484 display: flex;
485 flex-direction: column;
486 gap: 1px;
487 align-items: start;
488 width: 100%;
489 min-height: 44px;
490 justify-content: center;
491 padding: var(--space-xs) var(--space-sm);
492 border: 0;
493 border-radius: var(--radius-sm);
494 background: transparent;
495 color: var(--color-muted);
496 font: inherit;
497 font-size: var(--text-sm);
498 text-align: left;
499 text-decoration: none;
500 white-space: nowrap;
501 cursor: pointer;
502 transition: background-color var(--dur-micro) var(--ease-out);
503}
504
505.switcher__item:hover:not(:disabled) { background: var(--color-paper-2); }
506.switcher__item:disabled { opacity: 0.5; cursor: progress; }
507
508.switcher__add {
509 border-top: var(--rule-hair) solid var(--color-rule);
510 border-radius: 0;
511 margin-top: var(--space-2xs);
512 color: var(--color-accent-dim);
513}
514
515.switcher__logout { color: var(--color-muted); }
516.switcher__logout:hover:not(:disabled) { color: var(--color-error); }
517
518.caret {
519 display: inline-block;
520 width: 1ch;
521 color: var(--color-accent);
522 animation: blink 1.05s steps(2) infinite;
523}
524
525@keyframes blink { 50% { opacity: 0; } }
526@media (prefers-reduced-motion: reduce) { .caret { animation: none; } }
527
528.intro {
529 padding-block: var(--space-xl) var(--space-md);
530}
531
532h1 {
533 font-size: var(--text-xl);
534 margin: 0 0 var(--space-2xs);
535}
536
537.heading-target:focus { outline: none; }
538
539h2 {
540 margin: 0 0 var(--space-md);
541 font-size: var(--text-md);
542}
543
544.muted { color: var(--color-neutral); }
545.small { font-size: var(--text-sm); }
546
547.card {
548 background: var(--color-paper-2);
549 border: var(--rule-hair) solid var(--color-rule);
550 border-radius: var(--radius-md);
551 padding: var(--space-lg);
552 margin: var(--space-md) 0;
553}
554
555.error {
556 border-color: var(--color-error);
557 color: var(--color-error);
558}
559
560dl {
561 display: grid;
562 grid-template-columns: max-content minmax(0, 1fr);
563 align-items: baseline;
564 gap: var(--space-2xs) var(--space-md);
565 margin: 0;
566}
567
568.did {
569 word-break: break-all;
570}
571
572.did--secondary {
573 display: block;
574 margin-top: 2px;
575 font-size: var(--text-xs);
576 color: var(--color-neutral);
577}
578
579dt {
580 font-family: var(--font-mono);
581 font-size: var(--text-sm);
582 color: var(--color-neutral);
583}
584
585dd {
586 margin: 0;
587 overflow-wrap: anywhere;
588 min-width: 0;
589}
590
591.config-link {
592 display: inline-block;
593 margin-left: var(--space-xs);
594 font-family: var(--font-mono);
595 font-size: var(--text-xs);
596 white-space: nowrap;
597}
598
599code {
600 font-family: var(--font-mono);
601 font-size: var(--text-sm);
602 color: var(--color-ink);
603}
604
605.pubkey {
606 font-family: var(--font-mono);
607 font-size: var(--text-xs);
608 background: var(--color-paper);
609 border: var(--rule-hair) solid var(--color-rule);
610 padding: var(--space-sm);
611 border-radius: var(--radius-sm);
612 overflow-x: auto;
613 white-space: pre-wrap;
614 word-break: break-all;
615 margin: var(--space-xs) 0 var(--space-md);
616 color: var(--color-muted);
617}
618
619.repos {
620 list-style: none;
621 margin: 0;
622 padding: 0;
623}
624
625.repo {
626 display: flex;
627 align-items: start;
628 justify-content: space-between;
629 gap: var(--space-lg);
630 padding-block: var(--space-md);
631 border-top: var(--rule-hair) solid var(--color-rule);
632}
633
634.repo:first-child { border-top: 0; }
635
636.repo__main { min-width: 0; flex: 1; }
637
638.repo__head {
639 display: flex;
640 align-items: center;
641 flex-wrap: wrap;
642 gap: var(--space-xs);
643 margin-bottom: var(--space-xs);
644}
645
646.repo__name {
647 font-family: var(--font-mono);
648 font-size: var(--text-base);
649 color: var(--color-ink);
650 text-decoration: none;
651 border-bottom: var(--rule-hair) solid var(--color-rule);
652 overflow-wrap: anywhere;
653}
654
655.repo__name:hover { color: var(--color-accent); border-color: var(--color-accent); }
656
657.repo__meta {
658 display: flex;
659 flex-wrap: wrap;
660 gap: var(--space-xs) var(--space-lg);
661 margin: 0;
662}
663
664.repo__meta-item {
665 display: flex;
666 flex-direction: column;
667 gap: 1px;
668 min-width: 0;
669}
670
671.repo__meta dt {
672 font-family: var(--font-mono);
673 font-size: var(--text-xs);
674 letter-spacing: 0.06em;
675 text-transform: uppercase;
676 color: var(--color-neutral);
677}
678
679.repo__meta dd {
680 margin: 0;
681 font-size: var(--text-sm);
682 color: var(--color-muted);
683}
684
685.repo__did {
686 font-family: var(--font-mono);
687 word-break: break-all;
688}
689
690.repo__error {
691 margin: var(--space-xs) 0 0;
692 word-break: break-word;
693}
694
695.repo__actions {
696 display: flex;
697 flex-direction: column;
698 gap: var(--space-2xs);
699 flex: none;
700}
701
702.badge {
703 display: inline-block;
704 padding: 0.1rem 0.5rem;
705 border-radius: var(--radius-sm);
706 font-family: var(--font-mono);
707 font-size: var(--text-xs);
708 border: var(--rule-hair) solid var(--color-rule-interactive);
709 color: var(--color-muted);
710 margin-right: var(--space-2xs);
711}
712
713.badge::before {
714 margin-right: 0.4ch;
715}
716
717.badge-active { border-color: var(--color-ok); color: var(--color-ok); }
718.badge-active::before { content: "●"; }
719.badge-pending,
720.badge-enrolling { border-color: var(--color-warn); color: var(--color-warn); }
721.badge-pending::before,
722.badge-enrolling::before { content: "◐"; }
723.badge-error { border-color: var(--color-error); color: var(--color-error); }
724.badge-error::before { content: "⚠"; }
725.badge-disabled { border-color: var(--color-neutral); color: var(--color-neutral); }
726.badge-disabled::before { content: "⏸"; }
727
728button {
729 font-family: var(--font-mono);
730 font-size: var(--text-sm);
731 padding: 0.3rem 0.75rem;
732 border-radius: var(--radius-sm);
733 border: var(--rule-hair) solid var(--color-rule-interactive);
734 background: var(--color-paper-3);
735 color: var(--color-ink);
736 cursor: pointer;
737 white-space: nowrap;
738 transition: border-color var(--dur-micro) var(--ease-out), transform var(--dur-micro) var(--ease-out);
739}
740
741button:hover:not(:disabled) { border-color: var(--color-accent); transform: translateY(-1px); }
742button:active:not(:disabled) { transform: translateY(0); }
743
744button:disabled {
745 opacity: 0.5;
746 cursor: progress;
747}
748
749@media (max-width: 40rem) {
750 .repo {
751 flex-direction: column;
752 gap: var(--space-sm);
753 }
754 .repo__actions {
755 flex-direction: row;
756 flex-wrap: wrap;
757 }
758}
759
760.flash {
761 position: fixed;
762 right: var(--space-md);
763 bottom: var(--space-md);
764 z-index: var(--z-toast);
765 padding: var(--space-xs) var(--space-sm);
766 background: var(--color-paper-3);
767 border: var(--rule-hair) solid var(--color-accent);
768 color: var(--color-ink);
769 border-radius: var(--radius-sm);
770 font-family: var(--font-mono);
771 font-size: var(--text-sm);
772}
773
774.flash--error { border-color: var(--color-error); }
775</style>