Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun
at main 312 lines 7.8 kB view raw
1<script lang="ts"> 2 import { onMount } from 'svelte' 3 import { _ } from '../../lib/i18n' 4 import { api, ApiError, type InviteCode } from '../../lib/api' 5 import { toast } from '../../lib/toast.svelte' 6 import { formatDate } from '../../lib/date' 7 import type { Session } from '../../lib/types/api' 8 import Skeleton from '../Skeleton.svelte' 9 10 interface Props { 11 session: Session 12 } 13 14 let { session }: Props = $props() 15 16 let codes = $state<InviteCode[]>([]) 17 let loading = $state(true) 18 let creating = $state(false) 19 let disablingCode = $state<string | null>(null) 20 let createdCode = $state<string | null>(null) 21 let createdCodeCopied = $state(false) 22 let copiedCode = $state<string | null>(null) 23 24 onMount(async () => { 25 await loadCodes() 26 }) 27 28 async function loadCodes() { 29 loading = true 30 try { 31 const result = await api.getAccountInviteCodes(session.accessJwt) 32 codes = result.codes 33 } catch (e) { 34 toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.loadFailed')) 35 } finally { 36 loading = false 37 } 38 } 39 40 async function handleCreate() { 41 creating = true 42 try { 43 const result = await api.createInviteCode(session.accessJwt, 1) 44 createdCode = result.code 45 await loadCodes() 46 } catch (e) { 47 toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.createFailed')) 48 } finally { 49 creating = false 50 } 51 } 52 53 function dismissCreated() { 54 createdCode = null 55 createdCodeCopied = false 56 } 57 58 function copyCreatedCode() { 59 if (createdCode) { 60 navigator.clipboard.writeText(createdCode) 61 createdCodeCopied = true 62 } 63 } 64 65 async function disableCode(code: string) { 66 if (!confirm($_('inviteCodes.disableConfirm', { values: { code } }))) return 67 disablingCode = code 68 try { 69 await api.disableInviteCodes(session.accessJwt, [code]) 70 codes = codes.map(c => c.code === code ? { ...c, disabled: true } : c) 71 toast.success($_('inviteCodes.disableSuccess')) 72 } catch (e) { 73 toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.disableFailed')) 74 } finally { 75 disablingCode = null 76 } 77 } 78 79 function copyCode(code: string) { 80 navigator.clipboard.writeText(code) 81 copiedCode = code 82 setTimeout(() => { 83 if (copiedCode === code) { 84 copiedCode = null 85 } 86 }, 2000) 87 } 88</script> 89 90<div class="invite-codes"> 91 {#if createdCode} 92 <div class="created-code"> 93 <h3>{$_('inviteCodes.created')}</h3> 94 <div class="code-display"> 95 <code>{createdCode}</code> 96 <button class="sm" onclick={copyCreatedCode}> 97 {createdCodeCopied ? $_('common.copied') : $_('common.copyToClipboard')} 98 </button> 99 </div> 100 <button class="ghost sm" onclick={dismissCreated}>{$_('common.done')}</button> 101 </div> 102 {/if} 103 104 {#if session.isAdmin} 105 <div class="actions"> 106 <button onclick={handleCreate} disabled={creating}> 107 {creating ? $_('common.creating') : $_('inviteCodes.createNew')} 108 </button> 109 </div> 110 {/if} 111 112 <section class="list-section"> 113 <h2>{$_('inviteCodes.yourCodes')}</h2> 114 {#if loading} 115 <ul class="code-list"> 116 {#each Array(3) as _} 117 <li class="code-item skeleton-item"> 118 <div class="code-main"> 119 <Skeleton variant="line" size="medium" /> 120 </div> 121 <div class="code-meta"> 122 <Skeleton variant="line" size="short" /> 123 <Skeleton variant="line" size="tiny" /> 124 </div> 125 </li> 126 {/each} 127 </ul> 128 {:else if codes.length === 0} 129 <p class="empty">{$_('inviteCodes.noCodes')}</p> 130 {:else} 131 <ul class="code-list"> 132 {#each codes as code} 133 <li class="code-item" class:disabled={code.disabled} class:used={code.uses.length > 0 && code.available === 0}> 134 <div class="code-main"> 135 <code class="code-value">{code.code}</code> 136 <button 137 class="tertiary sm copy-btn" 138 onclick={() => copyCode(code.code)} 139 > 140 {copiedCode === code.code ? $_('common.copied') : $_('inviteCodes.copy')} 141 </button> 142 </div> 143 <div class="code-meta"> 144 <span class="date">{$_('inviteCodes.createdOn', { values: { date: formatDate(code.createdAt) } })}</span> 145 {#if code.disabled} 146 <span class="status disabled">{$_('inviteCodes.disabled')}</span> 147 {:else if code.uses.length > 0} 148 <span class="status used">{$_('inviteCodes.used', { values: { handle: code.uses[0].usedByHandle || code.uses[0].usedBy.split(':').pop() } })}</span> 149 {:else if code.available === 0} 150 <span class="status spent">{$_('inviteCodes.spent')}</span> 151 {:else} 152 <span class="status available">{$_('inviteCodes.available')}</span> 153 {/if} 154 </div> 155 </li> 156 {/each} 157 </ul> 158 {/if} 159 </section> 160</div> 161 162<style> 163 .invite-codes { 164 max-width: var(--width-lg); 165 } 166 167 .created-code { 168 padding: var(--space-5); 169 background: var(--success-bg); 170 border: 1px solid var(--success-border); 171 border-radius: var(--radius-xl); 172 margin-bottom: var(--space-6); 173 } 174 175 .created-code h3 { 176 margin: 0 0 var(--space-4) 0; 177 color: var(--success-text); 178 } 179 180 .code-display { 181 display: flex; 182 align-items: center; 183 gap: var(--space-4); 184 background: var(--bg-card); 185 padding: var(--space-4); 186 border-radius: var(--radius-md); 187 margin-bottom: var(--space-4); 188 } 189 190 .code-display code { 191 font-size: var(--text-lg); 192 font-family: var(--font-mono); 193 flex: 1; 194 } 195 196 .actions { 197 margin-bottom: var(--space-6); 198 } 199 200 .list-section h2 { 201 font-size: var(--text-lg); 202 margin: 0 0 var(--space-4) 0; 203 } 204 205 .empty { 206 color: var(--text-secondary); 207 padding: var(--space-6); 208 text-align: center; 209 } 210 211 .code-list { 212 list-style: none; 213 padding: 0; 214 margin: 0; 215 display: flex; 216 flex-direction: column; 217 gap: var(--space-3); 218 } 219 220 .code-item { 221 padding: var(--space-4); 222 background: var(--bg-secondary); 223 border: 1px solid var(--border-color); 224 border-radius: var(--radius-lg); 225 } 226 227 .skeleton-item { 228 pointer-events: none; 229 } 230 231 .code-item.disabled { 232 opacity: 0.6; 233 } 234 235 .code-item.used { 236 background: var(--bg-tertiary); 237 } 238 239 .code-main { 240 display: flex; 241 align-items: center; 242 gap: var(--space-3); 243 margin-bottom: var(--space-2); 244 } 245 246 .code-value { 247 font-family: var(--font-mono); 248 font-size: var(--text-sm); 249 padding: var(--space-2) var(--space-3); 250 background: var(--bg-card); 251 border-radius: var(--radius-md); 252 } 253 254 .copy-btn { 255 flex-shrink: 0; 256 } 257 258 .danger-text { 259 color: var(--error-text); 260 flex-shrink: 0; 261 } 262 263 .code-meta { 264 display: flex; 265 gap: var(--space-4); 266 font-size: var(--text-sm); 267 align-items: center; 268 flex-wrap: wrap; 269 } 270 271 .date { 272 color: var(--text-secondary); 273 } 274 275 .status { 276 padding: var(--space-1) var(--space-2); 277 border-radius: var(--radius-md); 278 font-size: var(--text-xs); 279 } 280 281 .status.available { 282 background: var(--success-bg); 283 color: var(--success-text); 284 } 285 286 .status.used { 287 background: var(--bg-secondary); 288 color: var(--text-secondary); 289 } 290 291 .status.spent { 292 background: var(--bg-tertiary); 293 color: var(--text-tertiary); 294 } 295 296 .status.disabled { 297 background: var(--error-bg); 298 color: var(--error-text); 299 } 300 301 @media (max-width: 500px) { 302 .code-display { 303 flex-direction: column; 304 align-items: stretch; 305 } 306 307 .code-main { 308 flex-direction: column; 309 align-items: stretch; 310 } 311 } 312</style>