Our Personal Data Server from scratch!
tranquil.farm
oauth
atproto
pds
rust
postgresql
objectstorage
fun
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>