mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

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') 18const { data: accountsData, refresh: refreshAccounts } = await useFetch<AccountsPayload>('/api/me/accounts') 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 [ref, sha] = entries[0]! 180 return `${ref} @ ${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">&gt;</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>