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', { 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">&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 <a class="config-link" :href="`https://github.com/settings/installations/${data.installation.id}`" target="_blank" rel="noopener noreferrer">configure on GitHub &nearr;</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>