this repo has no description
at main 12 kB view raw
1<script lang="ts"> 2 import { onMount } from 'svelte' 3 import { getAuthState } from '../lib/auth.svelte' 4 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 5 import { api, ApiError, type VerificationMethod, type DidDocument } from '../lib/api' 6 import { _ } from '../lib/i18n' 7 import type { Session } from '../lib/types/api' 8 import { toast } from '../lib/toast.svelte' 9 10 const auth = $derived(getAuthState()) 11 12 function getSession(): Session | null { 13 return auth.kind === 'authenticated' ? auth.session : null 14 } 15 16 function isLoading(): boolean { 17 return auth.kind === 'loading' 18 } 19 20 const session = $derived(getSession()) 21 const authLoading = $derived(isLoading()) 22 23 let loading = $state(true) 24 let saving = $state(false) 25 let didDocument = $state<DidDocument | null>(null) 26 let verificationMethods = $state<VerificationMethod[]>([]) 27 let alsoKnownAs = $state<string[]>([]) 28 let serviceEndpoint = $state('') 29 let newKeyId = $state('#atproto') 30 let newKeyPublic = $state('') 31 let newHandle = $state('') 32 33 $effect(() => { 34 if (!authLoading && !session) { 35 navigate(routes.login) 36 } 37 }) 38 39 onMount(async () => { 40 if (!session) return 41 try { 42 didDocument = await api.getDidDocument(session.accessJwt) 43 verificationMethods = didDocument.verificationMethod.map(vm => ({ 44 id: vm.id.replace(didDocument!.id, ''), 45 type: vm.type, 46 publicKeyMultibase: vm.publicKeyMultibase 47 })) 48 alsoKnownAs = [...didDocument.alsoKnownAs] 49 const pdsService = didDocument.service.find(s => s.id === '#atproto_pds') 50 serviceEndpoint = pdsService?.serviceEndpoint || '' 51 } catch (e) { 52 toast.error(e instanceof ApiError ? e.message : $_('didEditor.loadFailed')) 53 } finally { 54 loading = false 55 } 56 }) 57 58 function addVerificationMethod() { 59 if (!newKeyId || !newKeyPublic) return 60 if (!newKeyPublic.startsWith('z')) { 61 toast.error($_('didEditor.invalidMultibase')) 62 return 63 } 64 verificationMethods = [...verificationMethods, { 65 id: newKeyId.startsWith('#') ? newKeyId : `#${newKeyId}`, 66 type: 'Multikey', 67 publicKeyMultibase: newKeyPublic 68 }] 69 newKeyId = '#atproto' 70 newKeyPublic = '' 71 } 72 73 function removeVerificationMethod(index: number) { 74 verificationMethods = verificationMethods.filter((_, i) => i !== index) 75 } 76 77 function addHandle() { 78 if (!newHandle) return 79 if (!newHandle.startsWith('at://')) { 80 toast.error($_('didEditor.invalidHandle')) 81 return 82 } 83 alsoKnownAs = [...alsoKnownAs, newHandle] 84 newHandle = '' 85 } 86 87 function removeHandle(index: number) { 88 alsoKnownAs = alsoKnownAs.filter((_, i) => i !== index) 89 } 90 91 async function handleSave() { 92 if (!session) return 93 saving = true 94 try { 95 await api.updateDidDocument(session.accessJwt, { 96 verificationMethods: verificationMethods.length > 0 ? verificationMethods : undefined, 97 alsoKnownAs: alsoKnownAs.length > 0 ? alsoKnownAs : undefined, 98 serviceEndpoint: serviceEndpoint || undefined 99 }) 100 toast.success($_('didEditor.success')) 101 didDocument = await api.getDidDocument(session.accessJwt) 102 } catch (e) { 103 toast.error(e instanceof ApiError ? e.message : $_('didEditor.saveFailed')) 104 } finally { 105 saving = false 106 } 107 } 108</script> 109 110<div class="page"> 111 <header> 112 <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 113 <h1>{$_('didEditor.title')}</h1> 114 </header> 115 116 {#if loading} 117 <div class="skeleton-sections"> 118 <div class="skeleton-section small"></div> 119 <div class="skeleton-section large"></div> 120 <div class="skeleton-section"></div> 121 <div class="skeleton-section"></div> 122 </div> 123 {:else} 124 <div class="help-section"> 125 <h3>{$_('didEditor.helpTitle')}</h3> 126 <p>{$_('didEditor.helpText')}</p> 127 </div> 128 129 <section> 130 <h2>{$_('didEditor.preview')}</h2> 131 <pre class="did-preview">{JSON.stringify(didDocument, null, 2)}</pre> 132 </section> 133 134 <section> 135 <h2>{$_('didEditor.verificationMethods')}</h2> 136 <p class="description">{$_('didEditor.verificationMethodsDesc')}</p> 137 138 {#if verificationMethods.length > 0} 139 <ul class="key-list"> 140 {#each verificationMethods as method, index} 141 <li class="key-item"> 142 <div class="key-info"> 143 <span class="key-id">{method.id}</span> 144 <span class="key-type">{method.type}</span> 145 <code class="key-value">{method.publicKeyMultibase}</code> 146 </div> 147 <button type="button" class="danger-link" onclick={() => removeVerificationMethod(index)}> 148 {$_('didEditor.removeKey')} 149 </button> 150 </li> 151 {/each} 152 </ul> 153 {:else} 154 <p class="empty-state">{$_('didEditor.noKeys')}</p> 155 {/if} 156 157 <div class="add-form"> 158 <h4>{$_('didEditor.addKey')}</h4> 159 <div class="field-row"> 160 <div class="field small"> 161 <label for="key-id">{$_('didEditor.keyId')}</label> 162 <input 163 id="key-id" 164 type="text" 165 bind:value={newKeyId} 166 placeholder={$_('didEditor.keyIdPlaceholder')} 167 /> 168 </div> 169 <div class="field large"> 170 <label for="key-public">{$_('didEditor.publicKey')}</label> 171 <input 172 id="key-public" 173 type="text" 174 bind:value={newKeyPublic} 175 placeholder={$_('didEditor.publicKeyPlaceholder')} 176 /> 177 </div> 178 <button type="button" class="add-btn" onclick={addVerificationMethod} disabled={!newKeyId || !newKeyPublic}> 179 {$_('didEditor.addKey')} 180 </button> 181 </div> 182 </div> 183 </section> 184 185 <section> 186 <h2>{$_('didEditor.alsoKnownAs')}</h2> 187 <p class="description">{$_('didEditor.alsoKnownAsDesc')}</p> 188 189 {#if alsoKnownAs.length > 0} 190 <ul class="handle-list"> 191 {#each alsoKnownAs as handle, index} 192 <li class="handle-item"> 193 <span>{handle}</span> 194 <button type="button" class="danger-link" onclick={() => removeHandle(index)}> 195 {$_('didEditor.removeHandle')} 196 </button> 197 </li> 198 {/each} 199 </ul> 200 {:else} 201 <p class="empty-state">{$_('didEditor.noHandles')}</p> 202 {/if} 203 204 <div class="add-form"> 205 <div class="field-row"> 206 <div class="field large"> 207 <label for="new-handle">{$_('didEditor.handle')}</label> 208 <input 209 id="new-handle" 210 type="text" 211 bind:value={newHandle} 212 placeholder={$_('didEditor.handlePlaceholder')} 213 /> 214 </div> 215 <button type="button" class="add-btn" onclick={addHandle} disabled={!newHandle}> 216 {$_('didEditor.addHandle')} 217 </button> 218 </div> 219 </div> 220 </section> 221 222 <section> 223 <h2>{$_('didEditor.serviceEndpoint')}</h2> 224 <p class="description">{$_('didEditor.serviceEndpointDesc')}</p> 225 <div class="field"> 226 <label for="service-endpoint">{$_('didEditor.currentPds')}</label> 227 <input 228 id="service-endpoint" 229 type="url" 230 bind:value={serviceEndpoint} 231 placeholder="https://pds.example.com" 232 /> 233 </div> 234 </section> 235 236 <div class="actions"> 237 <button onclick={handleSave} disabled={saving}> 238 {saving ? $_('common.saving') : $_('common.save')} 239 </button> 240 </div> 241 {/if} 242</div> 243 244<style> 245 .page { 246 max-width: var(--width-lg); 247 margin: 0 auto; 248 padding: var(--space-7); 249 } 250 251 header { 252 margin-bottom: var(--space-7); 253 } 254 255 .back { 256 color: var(--text-secondary); 257 text-decoration: none; 258 font-size: var(--text-sm); 259 } 260 261 .back:hover { 262 color: var(--accent); 263 } 264 265 h1 { 266 margin: var(--space-2) 0 0 0; 267 } 268 269 .help-section { 270 background: var(--info-bg, #e0f2fe); 271 border: 1px solid var(--info-border, #7dd3fc); 272 border-radius: var(--radius-xl); 273 padding: var(--space-5) var(--space-6); 274 margin-bottom: var(--space-6); 275 } 276 277 .help-section h3 { 278 margin: 0 0 var(--space-2) 0; 279 color: var(--info-text, #0369a1); 280 font-size: var(--text-base); 281 } 282 283 .help-section p { 284 margin: 0; 285 color: var(--info-text, #0369a1); 286 font-size: var(--text-sm); 287 } 288 289 section { 290 padding: var(--space-6); 291 background: var(--bg-secondary); 292 border-radius: var(--radius-xl); 293 margin-bottom: var(--space-6); 294 } 295 296 section h2 { 297 margin: 0 0 var(--space-2) 0; 298 font-size: var(--text-lg); 299 } 300 301 .description { 302 color: var(--text-secondary); 303 font-size: var(--text-sm); 304 margin-bottom: var(--space-4); 305 } 306 307 .did-preview { 308 background: var(--bg-input); 309 padding: var(--space-4); 310 border-radius: var(--radius-md); 311 font-size: var(--text-xs); 312 overflow-x: auto; 313 white-space: pre-wrap; 314 word-break: break-all; 315 max-height: 300px; 316 overflow-y: auto; 317 } 318 319 .key-list, .handle-list { 320 list-style: none; 321 padding: 0; 322 margin: 0 0 var(--space-4) 0; 323 } 324 325 .key-item, .handle-item { 326 display: flex; 327 justify-content: space-between; 328 align-items: flex-start; 329 padding: var(--space-3) var(--space-4); 330 background: var(--bg-card); 331 border: 1px solid var(--border-color); 332 border-radius: var(--radius-md); 333 margin-bottom: var(--space-2); 334 gap: var(--space-4); 335 } 336 337 .key-info { 338 display: flex; 339 flex-direction: column; 340 gap: var(--space-1); 341 flex: 1; 342 min-width: 0; 343 } 344 345 .key-id { 346 font-weight: var(--font-medium); 347 font-size: var(--text-sm); 348 } 349 350 .key-type { 351 color: var(--text-secondary); 352 font-size: var(--text-xs); 353 } 354 355 .key-value { 356 font-size: var(--text-xs); 357 background: var(--bg-input); 358 padding: var(--space-1) var(--space-2); 359 border-radius: var(--radius-sm); 360 word-break: break-all; 361 } 362 363 .handle-item span { 364 font-family: ui-monospace, monospace; 365 font-size: var(--text-sm); 366 } 367 368 .danger-link { 369 background: none; 370 border: none; 371 color: var(--error-text); 372 cursor: pointer; 373 font-size: var(--text-xs); 374 padding: var(--space-1) var(--space-2); 375 white-space: nowrap; 376 } 377 378 .danger-link:hover { 379 text-decoration: underline; 380 } 381 382 .empty-state { 383 color: var(--text-muted); 384 font-size: var(--text-sm); 385 font-style: italic; 386 padding: var(--space-4); 387 text-align: center; 388 background: var(--bg-card); 389 border-radius: var(--radius-md); 390 margin-bottom: var(--space-4); 391 } 392 393 .add-form { 394 background: var(--bg-card); 395 border: 1px solid var(--border-color); 396 border-radius: var(--radius-lg); 397 padding: var(--space-4); 398 } 399 400 .add-form h4 { 401 margin: 0 0 var(--space-3) 0; 402 font-size: var(--text-sm); 403 color: var(--text-secondary); 404 } 405 406 .field-row { 407 display: flex; 408 gap: var(--space-3); 409 align-items: flex-end; 410 } 411 412 .field { 413 display: flex; 414 flex-direction: column; 415 gap: var(--space-1); 416 } 417 418 .field.small { 419 flex: 0 0 120px; 420 } 421 422 .field.large { 423 flex: 1; 424 } 425 426 .field label { 427 font-size: var(--text-xs); 428 color: var(--text-secondary); 429 } 430 431 .add-btn { 432 white-space: nowrap; 433 } 434 435 .actions { 436 display: flex; 437 gap: var(--space-3); 438 justify-content: flex-end; 439 margin-top: var(--space-6); 440 } 441 442 @media (max-width: 600px) { 443 .field-row { 444 flex-direction: column; 445 align-items: stretch; 446 } 447 448 .field.small, .field.large { 449 flex: none; 450 } 451 452 .add-btn { 453 width: 100%; 454 } 455 } 456 457 .skeleton-sections { 458 display: flex; 459 flex-direction: column; 460 gap: var(--space-6); 461 } 462 463 .skeleton-section { 464 height: 180px; 465 background: var(--bg-secondary); 466 border-radius: var(--radius-xl); 467 animation: skeleton-pulse 1.5s ease-in-out infinite; 468 } 469 470 .skeleton-section.small { 471 height: 80px; 472 } 473 474 .skeleton-section.large { 475 height: 250px; 476 } 477 478 @keyframes skeleton-pulse { 479 0%, 100% { opacity: 1; } 480 50% { opacity: 0.5; } 481 } 482</style>