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 { DashboardPayload, DashboardRepo } from '~~/server/api/me/dashboard.get' 4 5definePageMeta({ 6 middleware: ['authenticated'], 7}) 8 9useSeoMeta({ 10 title: 'Dashboard · synchub.to', 11 description: 'Your synchub.to mirror status.', 12}) 13 14const { data, refresh, error } = await useFetch<DashboardPayload>('/api/me/dashboard') 15 16const flash = ref<string | null>(null) 17const flashIsError = computed(() => flash.value?.startsWith('Error:') ?? false) 18const pendingAction = ref<string | null>(null) 19 20const heading = useTemplateRef('heading') 21const router = useRouter() 22router.afterEach(() => { 23 nextTick(() => heading.value?.focus({ focusVisible: false })) 24}) 25 26function actionKey(scope: string, id: number | string) { 27 return `${scope}:${id}` 28} 29 30async function runAction(key: string, fn: () => Promise<unknown>, successMessage: string) { 31 pendingAction.value = key 32 flash.value = null 33 try { 34 await fn() 35 flash.value = successMessage 36 await refresh() 37 } 38 catch (err: unknown) { 39 const message = (err as { data?: { statusMessage?: string }, message?: string } | null)?.data?.statusMessage 40 ?? (err as Error | null)?.message 41 ?? 'something went wrong' 42 flash.value = `Error: ${message}` 43 } 44 finally { 45 pendingAction.value = null 46 } 47} 48 49// Typed Nitro routes use `:id` placeholders; passing the dynamic path to 50// `$fetch` blows up TS' pattern matcher with "excessive stack depth". Posting 51// a no-body request through native `fetch` and parsing the JSON manually 52// sidesteps that. Same-origin, so cookies are sent automatically. 53async function postAction(url: string): Promise<unknown> { 54 const response = await fetch(url, { method: 'POST', credentials: 'same-origin' }) 55 if (!response.ok) { 56 let statusMessage = response.statusText 57 try { 58 const body: unknown = await response.json() 59 if (body && typeof body === 'object' && 'statusMessage' in body && typeof body.statusMessage === 'string') { 60 statusMessage = body.statusMessage 61 } 62 } 63 catch { 64 // body wasn't json; keep statusText 65 } 66 throw Object.assign(new Error(statusMessage), { statusCode: response.status }) 67 } 68 return response.json() 69} 70 71function resync(repo: DashboardRepo) { 72 return runAction( 73 actionKey('resync', repo.id), 74 () => postAction(`/api/repos/${repo.id}/resync`), 75 `Queued resync for ${repo.githubFullName}.`, 76 ) 77} 78 79function disable(repo: DashboardRepo) { 80 return runAction( 81 actionKey('disable', repo.id), 82 () => postAction(`/api/repos/${repo.id}/disable`), 83 `Disabled sync for ${repo.githubFullName}.`, 84 ) 85} 86 87function enable(repo: DashboardRepo) { 88 return runAction( 89 actionKey('enable', repo.id), 90 () => postAction(`/api/repos/${repo.id}/enable`), 91 `Re-enabled sync for ${repo.githubFullName}.`, 92 ) 93} 94 95function rotateKey() { 96 return runAction( 97 'rotate-key', 98 () => postAction('/api/me/rotate-key'), 99 'Queued SSH key rotation.', 100 ) 101} 102 103async function logout() { 104 pendingAction.value = 'logout' 105 try { 106 await postAction('/api/me/logout') 107 await navigateTo('/') 108 } 109 catch { 110 pendingAction.value = null 111 flash.value = 'Error: could not sign out.' 112 } 113} 114 115function summariseRefs(refs: Record<string, string>): string { 116 const entries = Object.entries(refs) 117 if (entries.length === 0) return '—' 118 if (entries.length === 1) { 119 const [ref, sha] = entries[0]! 120 return `${ref} @ ${sha.slice(0, 7)}` 121 } 122 return `${entries.length} refs` 123} 124 125function fmtDate(iso: string | null): string { 126 if (!iso) return '—' 127 try { 128 return new Date(iso).toLocaleString() 129 } 130 catch { 131 return iso 132 } 133} 134</script> 135 136<template> 137 <div class="page"> 138 <a class="skip-link" href="#main">skip to main content</a> 139 <header class="nav-term"> 140 <nav class="nav-term__line" aria-label="primary"><span class="prompt" aria-hidden="true">&gt;</span> <a class="nav-term__mark" href="/dashboard" aria-label="synchub.to dashboard"><SynchubMark :wordmark="true" :size="18" /></a> <button type="button" class="nav-term__logout" :disabled="pendingAction !== null" :aria-busy="pendingAction === 'logout'" @click="logout">{{ pendingAction === 'logout' ? 'logging out' : '--logout' }}</button></nav> 141 </header> 142 143 <div id="main" class="intro"> 144 <h1 ref="heading" tabindex="-1" class="heading-target">mirror status</h1> 145 <p class="muted"> 146 everything synchub is syncing for your connected GitHub installation. 147 </p> 148 </div> 149 150 <div v-if="error" class="card error" role="alert"> 151 <span aria-hidden="true"></span> failed to load dashboard: {{ error.message }} 152 </div> 153 154 <div v-else-if="data"> 155 <section class="card"> 156 <h2>identity</h2> 157 <dl> 158 <dt>tangled DID</dt> 159 <dd><code>{{ data.did }}</code></dd> 160 <template v-if="data.handle"> 161 <dt>handle</dt> 162 <dd>@{{ data.handle }}</dd> 163 </template> 164 <template v-if="data.installation"> 165 <dt>GitHub installation</dt> 166 <dd> 167 {{ data.installation.accountLogin }} 168 <span class="muted">({{ data.installation.accountType }}, install #{{ data.installation.id }})</span> 169 <span v-if="data.installation.suspendedAt" class="badge badge-error">suspended</span> 170 </dd> 171 </template> 172 <template v-else> 173 <dt>GitHub installation</dt> 174 <dd class="muted"> 175 no matching install row 176 </dd> 177 </template> 178 </dl> 179 <p class="muted small"> 180 connected to <strong>{{ data.installation?.accountLogin ?? 'this account' }}</strong>. 181 <a href="https://github.com/apps/synchub-to/installations/new">connect a different installation?</a> 182 </p> 183 </section> 184 185 <section class="card"> 186 <h2>SSH key</h2> 187 <template v-if="data.sshKey"> 188 <p class="small muted"> 189 created {{ fmtDate(data.sshKey.createdAt) }}<span v-if="data.sshKey.rotatedAt">, last rotated {{ fmtDate(data.sshKey.rotatedAt) }}</span>. 190 </p> 191 <pre class="pubkey">{{ data.sshKey.publicKey }}</pre> 192 </template> 193 <p v-else class="muted"> 194 no key on file yet. it's created automatically on first sign-in. 195 </p> 196 <button 197 type="button" 198 :disabled="pendingAction !== null" 199 :aria-busy="pendingAction === 'rotate-key'" 200 @click="rotateKey" 201 > 202 {{ pendingAction === 'rotate-key' ? 'Rotating' : 'Rotate SSH key' }} 203 </button> 204 </section> 205 206 <section class="card"> 207 <h2>repositories ({{ data.repos.length }})</h2> 208 <p v-if="data.repos.length === 0" class="muted"> 209 no repositories enrolled yet. new installs are backfilled in the background; refresh in a minute. 210 </p> 211 <ul v-else class="repos"> 212 <li v-for="repo in data.repos" :key="repo.id" class="repo"> 213 <div class="repo__main"> 214 <div class="repo__head"> 215 <a class="repo__name" :href="`https://github.com/${repo.githubFullName}`" rel="noopener noreferrer">{{ repo.githubFullName }}</a> 216 <span class="badge" :class="`badge-${repo.status}`">{{ repo.status }}</span> 217 <span v-if="repo.disabledAt" class="badge badge-disabled">disabled</span> 218 </div> 219 <dl class="repo__meta"> 220 <div class="repo__meta-item"> 221 <dt>tangled</dt> 222 <dd v-if="repo.tangledFullName" class="repo__did">{{ repo.tangledFullName }}</dd> 223 <dd v-else class="muted">not yet enrolled</dd> 224 </div> 225 <div class="repo__meta-item"> 226 <dt>last synced</dt> 227 <dd>{{ fmtDate(repo.lastSyncedAt) }}</dd> 228 </div> 229 <div class="repo__meta-item"> 230 <dt>refs</dt> 231 <dd>{{ summariseRefs(repo.lastSyncedRefs) }}</dd> 232 </div> 233 </dl> 234 <p v-if="repo.lastError" class="repo__error muted small"> 235 {{ repo.lastError }} 236 </p> 237 </div> 238 <div class="repo__actions"> 239 <button 240 type="button" 241 :disabled="pendingAction !== null" 242 :aria-busy="pendingAction === actionKey('resync', repo.id)" 243 @click="resync(repo)" 244 > 245 {{ pendingAction === actionKey('resync', repo.id) ? 'Resyncing' : 'Resync' }} 246 </button> 247 <button 248 v-if="repo.disabledAt" 249 type="button" 250 :disabled="pendingAction !== null" 251 :aria-busy="pendingAction === actionKey('enable', repo.id)" 252 @click="enable(repo)" 253 > 254 {{ pendingAction === actionKey('enable', repo.id) ? 'Enabling' : 'Enable' }} 255 </button> 256 <button 257 v-else 258 type="button" 259 :disabled="pendingAction !== null" 260 :aria-busy="pendingAction === actionKey('disable', repo.id)" 261 @click="disable(repo)" 262 > 263 {{ pendingAction === actionKey('disable', repo.id) ? 'Disabling' : 'Disable' }} 264 </button> 265 </div> 266 </li> 267 </ul> 268 </section> 269 270 <p 271 v-if="flash" 272 class="flash" 273 :class="{ 'flash--error': flashIsError }" 274 :role="flashIsError ? 'alert' : 'status'" 275 > 276 <span aria-hidden="true">{{ flashIsError ? '' : '' }}</span> {{ flash }} 277 </p> 278 </div> 279 </div> 280</template> 281 282<style scoped> 283.page { 284 max-width: 64rem; 285 margin: 0 auto; 286 padding-inline: var(--page-gutter); 287 color: var(--color-ink); 288} 289 290.nav-term { 291 padding: var(--space-md) 0; 292 border-bottom: var(--rule-hair) solid var(--color-rule); 293} 294 295.nav-term__line { 296 display: flex; 297 align-items: center; 298 flex-wrap: wrap; 299 gap: 0.6ch; 300 font-family: var(--font-mono); 301 font-size: var(--text-sm); 302 margin: 0; 303} 304 305.nav-term__line .prompt { color: var(--color-accent); } 306 307.nav-term__mark { 308 display: inline-flex; 309 text-decoration: none; 310 margin-right: 1.2ch; 311} 312 313.nav-term__args { 314 display: inline-flex; 315 align-items: center; 316 gap: 0.9ch; 317 list-style: none; 318 margin: 0; 319 padding: 0; 320} 321 322.nav-term__args a { 323 display: inline-flex; 324 align-items: center; 325 min-height: 44px; 326 color: var(--color-muted); 327 text-decoration: underline; 328 text-underline-offset: 2px; 329 white-space: nowrap; 330} 331 332.nav-term__args a:hover { color: var(--color-ink); } 333 334.nav-term__logout { 335 margin-left: auto; 336 padding: 0.2rem 0.7rem; 337 border: var(--rule-hair) solid var(--color-rule-interactive); 338 border-radius: var(--radius-sm); 339 background: transparent; 340 color: var(--color-muted); 341 font-family: var(--font-mono); 342 font-size: var(--text-sm); 343 white-space: nowrap; 344 cursor: pointer; 345 transition: border-color var(--dur-micro) var(--ease-out), color var(--dur-micro) var(--ease-out); 346} 347 348.nav-term__logout:hover:not(:disabled) { border-color: var(--color-accent); color: var(--color-ink); } 349.nav-term__logout:disabled { opacity: 0.5; cursor: progress; } 350 351.caret { 352 display: inline-block; 353 width: 1ch; 354 color: var(--color-accent); 355 animation: blink 1.05s steps(2) infinite; 356} 357 358@keyframes blink { 50% { opacity: 0; } } 359@media (prefers-reduced-motion: reduce) { .caret { animation: none; } } 360 361.intro { 362 padding-block: var(--space-xl) var(--space-md); 363} 364 365h1 { 366 font-size: var(--text-xl); 367 margin: 0 0 var(--space-2xs); 368} 369 370.heading-target:focus { outline: none; } 371 372h2 { 373 margin: 0 0 var(--space-md); 374 font-size: var(--text-md); 375} 376 377.muted { color: var(--color-neutral); } 378.small { font-size: var(--text-sm); } 379 380.card { 381 background: var(--color-paper-2); 382 border: var(--rule-hair) solid var(--color-rule); 383 border-radius: var(--radius-md); 384 padding: var(--space-lg); 385 margin: var(--space-md) 0; 386} 387 388.error { 389 border-color: var(--color-error); 390 color: var(--color-error); 391} 392 393dl { 394 display: grid; 395 grid-template-columns: max-content minmax(0, 1fr); 396 gap: var(--space-2xs) var(--space-md); 397 margin: 0 0 var(--space-sm); 398} 399 400dt { 401 font-family: var(--font-mono); 402 font-size: var(--text-sm); 403 color: var(--color-neutral); 404} 405 406dd { 407 margin: 0; 408 overflow-wrap: anywhere; 409 min-width: 0; 410} 411 412code { 413 font-family: var(--font-mono); 414 font-size: var(--text-sm); 415 color: var(--color-ink); 416} 417 418.pubkey { 419 font-family: var(--font-mono); 420 font-size: var(--text-xs); 421 background: var(--color-paper); 422 border: var(--rule-hair) solid var(--color-rule); 423 padding: var(--space-sm); 424 border-radius: var(--radius-sm); 425 overflow-x: auto; 426 white-space: pre-wrap; 427 word-break: break-all; 428 margin: var(--space-xs) 0 var(--space-md); 429 color: var(--color-muted); 430} 431 432.repos { 433 list-style: none; 434 margin: 0; 435 padding: 0; 436} 437 438.repo { 439 display: flex; 440 align-items: start; 441 justify-content: space-between; 442 gap: var(--space-lg); 443 padding-block: var(--space-md); 444 border-top: var(--rule-hair) solid var(--color-rule); 445} 446 447.repo:first-child { border-top: 0; } 448 449.repo__main { min-width: 0; flex: 1; } 450 451.repo__head { 452 display: flex; 453 align-items: center; 454 flex-wrap: wrap; 455 gap: var(--space-xs); 456 margin-bottom: var(--space-xs); 457} 458 459.repo__name { 460 font-family: var(--font-mono); 461 font-size: var(--text-base); 462 color: var(--color-ink); 463 text-decoration: none; 464 border-bottom: var(--rule-hair) solid var(--color-rule); 465 overflow-wrap: anywhere; 466} 467 468.repo__name:hover { color: var(--color-accent); border-color: var(--color-accent); } 469 470.repo__meta { 471 display: flex; 472 flex-wrap: wrap; 473 gap: var(--space-xs) var(--space-lg); 474 margin: 0; 475} 476 477.repo__meta-item { 478 display: flex; 479 flex-direction: column; 480 gap: 1px; 481 min-width: 0; 482} 483 484.repo__meta dt { 485 font-family: var(--font-mono); 486 font-size: var(--text-xs); 487 letter-spacing: 0.06em; 488 text-transform: uppercase; 489 color: var(--color-neutral); 490} 491 492.repo__meta dd { 493 margin: 0; 494 font-size: var(--text-sm); 495 color: var(--color-muted); 496} 497 498.repo__did { 499 font-family: var(--font-mono); 500 word-break: break-all; 501} 502 503.repo__error { 504 margin: var(--space-xs) 0 0; 505 word-break: break-word; 506} 507 508.repo__actions { 509 display: flex; 510 flex-direction: column; 511 gap: var(--space-2xs); 512 flex: none; 513} 514 515.badge { 516 display: inline-block; 517 padding: 0.1rem 0.5rem; 518 border-radius: var(--radius-sm); 519 font-family: var(--font-mono); 520 font-size: var(--text-xs); 521 border: var(--rule-hair) solid var(--color-rule-interactive); 522 color: var(--color-muted); 523 margin-right: var(--space-2xs); 524} 525 526.badge::before { 527 margin-right: 0.4ch; 528} 529 530.badge-active { border-color: var(--color-ok); color: var(--color-ok); } 531.badge-active::before { content: "●"; } 532.badge-pending, 533.badge-enrolling { border-color: var(--color-warn); color: var(--color-warn); } 534.badge-pending::before, 535.badge-enrolling::before { content: "◐"; } 536.badge-error { border-color: var(--color-error); color: var(--color-error); } 537.badge-error::before { content: "⚠"; } 538.badge-disabled { border-color: var(--color-neutral); color: var(--color-neutral); } 539.badge-disabled::before { content: "⏸"; } 540 541button { 542 font-family: var(--font-mono); 543 font-size: var(--text-sm); 544 padding: 0.3rem 0.75rem; 545 border-radius: var(--radius-sm); 546 border: var(--rule-hair) solid var(--color-rule-interactive); 547 background: var(--color-paper-3); 548 color: var(--color-ink); 549 cursor: pointer; 550 white-space: nowrap; 551 transition: border-color var(--dur-micro) var(--ease-out), transform var(--dur-micro) var(--ease-out); 552} 553 554button:hover:not(:disabled) { border-color: var(--color-accent); transform: translateY(-1px); } 555button:active:not(:disabled) { transform: translateY(0); } 556 557button:disabled { 558 opacity: 0.5; 559 cursor: progress; 560} 561 562@media (max-width: 40rem) { 563 .repo { 564 flex-direction: column; 565 gap: var(--space-sm); 566 } 567 .repo__actions { 568 flex-direction: row; 569 flex-wrap: wrap; 570 } 571} 572 573.flash { 574 position: fixed; 575 right: var(--space-md); 576 bottom: var(--space-md); 577 z-index: var(--z-toast); 578 padding: var(--space-xs) var(--space-sm); 579 background: var(--color-paper-3); 580 border: var(--rule-hair) solid var(--color-accent); 581 color: var(--color-ink); 582 border-radius: var(--radius-sm); 583 font-family: var(--font-mono); 584 font-size: var(--text-sm); 585} 586 587.flash--error { border-color: var(--color-error); } 588</style>