this repo has no description
at main 8.5 kB view raw
1<script lang="ts"> 2 import { getAuthState } from '../lib/auth.svelte' 3 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 4 import { api, type InviteCode, ApiError } from '../lib/api' 5 import { _ } from '../lib/i18n' 6 import { formatDate } from '../lib/date' 7 import { onMount } from 'svelte' 8 import type { Session } from '../lib/types/api' 9 import { toast } from '../lib/toast.svelte' 10 11 const auth = $derived(getAuthState()) 12 13 function getSession(): Session | null { 14 return auth.kind === 'authenticated' ? auth.session : null 15 } 16 17 function isLoading(): boolean { 18 return auth.kind === 'loading' 19 } 20 21 const session = $derived(getSession()) 22 const authLoading = $derived(isLoading()) 23 let codes = $state<InviteCode[]>([]) 24 let loading = $state(true) 25 let creating = $state(false) 26 let createdCode = $state<string | null>(null) 27 let createdCodeCopied = $state(false) 28 let copiedCode = $state<string | null>(null) 29 let inviteCodesEnabled = $state<boolean | null>(null) 30 31 onMount(async () => { 32 try { 33 const serverInfo = await api.describeServer() 34 inviteCodesEnabled = serverInfo.inviteCodeRequired 35 if (!serverInfo.inviteCodeRequired) { 36 navigate(routes.dashboard) 37 } 38 } catch { 39 navigate(routes.dashboard) 40 } 41 }) 42 43 $effect(() => { 44 if (!authLoading && !session) { 45 navigate(routes.login) 46 } 47 }) 48 $effect(() => { 49 if (session && inviteCodesEnabled) { 50 loadCodes() 51 } 52 }) 53 async function loadCodes() { 54 if (!session) return 55 loading = true 56 try { 57 const result = await api.getAccountInviteCodes(session.accessJwt) 58 codes = result.codes 59 } catch (e) { 60 toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.failedToLoad')) 61 } finally { 62 loading = false 63 } 64 } 65 async function handleCreate() { 66 if (!session) return 67 creating = true 68 try { 69 const result = await api.createInviteCode(session.accessJwt, 1) 70 createdCode = result.code 71 await loadCodes() 72 } catch (e) { 73 toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.failedToCreate')) 74 } finally { 75 creating = false 76 } 77 } 78 function dismissCreated() { 79 createdCode = null 80 createdCodeCopied = false 81 } 82 function copyCreatedCode() { 83 if (createdCode) { 84 navigator.clipboard.writeText(createdCode) 85 createdCodeCopied = true 86 } 87 } 88 function copyCode(code: string) { 89 navigator.clipboard.writeText(code) 90 copiedCode = code 91 setTimeout(() => { 92 if (copiedCode === code) { 93 copiedCode = null 94 } 95 }, 2000) 96 } 97</script> 98<div class="page"> 99 <header> 100 <a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a> 101 <h1>{$_('inviteCodes.title')}</h1> 102 </header> 103 <p class="description"> 104 {$_('inviteCodes.description')} 105 </p> 106 {#if createdCode} 107 <div class="created-code"> 108 <h3>{$_('inviteCodes.created')}</h3> 109 <div class="code-display"> 110 <code>{createdCode}</code> 111 <button class="copy" onclick={copyCreatedCode}> 112 {createdCodeCopied ? $_('common.copied') : $_('common.copyToClipboard')} 113 </button> 114 </div> 115 <button onclick={dismissCreated}>{$_('common.done')}</button> 116 </div> 117 {/if} 118 {#if session?.isAdmin} 119 <section class="create-section"> 120 <button onclick={handleCreate} disabled={creating}> 121 {creating ? $_('common.creating') : $_('inviteCodes.createNew')} 122 </button> 123 </section> 124 {/if} 125 <section class="list-section"> 126 <h2>{$_('inviteCodes.yourCodes')}</h2> 127 {#if loading} 128 <ul class="code-list"> 129 {#each Array(2) as _} 130 <li class="skeleton-item"></li> 131 {/each} 132 </ul> 133 {:else if codes.length === 0} 134 <p class="empty">{$_('inviteCodes.noCodes')}</p> 135 {:else} 136 <ul class="code-list"> 137 {#each codes as code} 138 <li class:disabled={code.disabled} class:used={code.uses.length > 0 && code.available === 0}> 139 <div class="code-main"> 140 <code>{code.code}</code> 141 <button 142 class="copy-small" 143 onclick={() => copyCode(code.code)} 144 title={copiedCode === code.code ? $_('common.copied') : $_('inviteCodes.copy')} 145 > 146 {copiedCode === code.code ? $_('common.copied') : $_('inviteCodes.copy')} 147 </button> 148 </div> 149 <div class="code-meta"> 150 <span class="date">{$_('inviteCodes.createdOn', { values: { date: formatDate(code.createdAt) } })}</span> 151 {#if code.disabled} 152 <span class="status disabled">{$_('inviteCodes.disabled')}</span> 153 {:else if code.uses.length > 0} 154 <span class="status used">{$_('inviteCodes.used', { values: { handle: code.uses[0].usedByHandle || code.uses[0].usedBy.split(':').pop() } })}</span> 155 {:else} 156 <span class="status available">{$_('inviteCodes.available')}</span> 157 {/if} 158 </div> 159 </li> 160 {/each} 161 </ul> 162 {/if} 163 </section> 164</div> 165<style> 166 .page { 167 max-width: var(--width-lg); 168 margin: 0 auto; 169 padding: var(--space-7); 170 } 171 172 header { 173 margin-bottom: var(--space-4); 174 } 175 176 .back { 177 color: var(--text-secondary); 178 text-decoration: none; 179 font-size: var(--text-sm); 180 } 181 182 .back:hover { 183 color: var(--accent); 184 } 185 186 h1 { 187 margin: var(--space-2) 0 0 0; 188 } 189 190 .description { 191 color: var(--text-secondary); 192 margin-bottom: var(--space-7); 193 } 194 195 .created-code { 196 padding: var(--space-6); 197 background: var(--success-bg); 198 border: 1px solid var(--success-border); 199 border-radius: var(--radius-xl); 200 margin-bottom: var(--space-7); 201 } 202 203 .created-code h3 { 204 margin: 0 0 var(--space-4) 0; 205 color: var(--success-text); 206 } 207 208 .code-display { 209 display: flex; 210 align-items: center; 211 gap: var(--space-4); 212 background: var(--bg-card); 213 padding: var(--space-4); 214 border-radius: var(--radius-md); 215 margin-bottom: var(--space-4); 216 } 217 218 .code-display code { 219 font-size: var(--text-lg); 220 font-family: ui-monospace, monospace; 221 flex: 1; 222 } 223 224 .copy { 225 padding: var(--space-2) var(--space-4); 226 background: var(--accent); 227 color: var(--text-inverse); 228 border: none; 229 border-radius: var(--radius-md); 230 cursor: pointer; 231 } 232 233 .copy:hover { 234 background: var(--accent-hover); 235 } 236 237 .create-section { 238 margin-bottom: var(--space-7); 239 } 240 241 section h2 { 242 font-size: var(--text-lg); 243 margin: 0 0 var(--space-4) 0; 244 } 245 246 .code-list { 247 list-style: none; 248 padding: 0; 249 margin: 0; 250 } 251 252 .code-list li { 253 padding: var(--space-4); 254 border: 1px solid var(--border-color); 255 border-radius: var(--radius-md); 256 margin-bottom: var(--space-2); 257 background: var(--bg-card); 258 } 259 260 .code-list li.disabled { 261 opacity: 0.6; 262 } 263 264 .code-list li.used { 265 background: var(--bg-secondary); 266 } 267 268 .code-main { 269 display: flex; 270 align-items: center; 271 gap: var(--space-2); 272 margin-bottom: var(--space-2); 273 } 274 275 .code-main code { 276 font-family: ui-monospace, monospace; 277 font-size: var(--text-sm); 278 } 279 280 .copy-small { 281 padding: var(--space-1) var(--space-2); 282 background: var(--bg-secondary); 283 border: 1px solid var(--border-color); 284 border-radius: var(--radius-md); 285 font-size: var(--text-xs); 286 cursor: pointer; 287 color: var(--text-primary); 288 } 289 290 .copy-small:hover { 291 background: var(--bg-input-disabled); 292 } 293 294 .code-meta { 295 display: flex; 296 gap: var(--space-4); 297 font-size: var(--text-sm); 298 } 299 300 .date { 301 color: var(--text-secondary); 302 } 303 304 .status { 305 padding: var(--space-1) var(--space-2); 306 border-radius: var(--radius-md); 307 font-size: var(--text-xs); 308 } 309 310 .status.available { 311 background: var(--success-bg); 312 color: var(--success-text); 313 } 314 315 .status.used { 316 background: var(--bg-secondary); 317 color: var(--text-secondary); 318 } 319 320 .status.disabled { 321 background: var(--error-bg); 322 color: var(--error-text); 323 } 324 325 .empty { 326 color: var(--text-secondary); 327 text-align: center; 328 padding: var(--space-7); 329 } 330 331 .skeleton-item { 332 height: 50px; 333 background: var(--bg-tertiary); 334 animation: skeleton-pulse 1.5s ease-in-out infinite; 335 } 336 337 @keyframes skeleton-pulse { 338 0%, 100% { opacity: 1; } 339 50% { opacity: 0.5; } 340 } 341</style>