🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: migrate admin UI to card-based layouts

Replaced table-based layouts with modern card components for better UX:
- Transcriptions tab now uses admin-transcriptions component
- Users tab now uses admin-users component
- Both tabs feature search, filtering, and click-to-view modals
- Removed ~500 lines of duplicate table-rendering code
- Cleaned up CSS (removed table/search/sort styles)
- Simplified JavaScript to just handle stats and modal events

All three admin tabs now have consistent card-based design.

💘 Generated with Crush

Co-Authored-By: Crush <crush@charm.land>

+791 -624
+317
src/components/admin-transcriptions.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + 4 + interface Transcription { 5 + id: string; 6 + original_filename: string; 7 + user_id: number; 8 + user_name: string | null; 9 + user_email: string; 10 + status: string; 11 + created_at: number; 12 + error_message?: string | null; 13 + } 14 + 15 + @customElement("admin-transcriptions") 16 + export class AdminTranscriptions extends LitElement { 17 + @state() transcriptions: Transcription[] = []; 18 + @state() searchQuery = ""; 19 + @state() isLoading = true; 20 + @state() error: string | null = null; 21 + 22 + static override styles = css` 23 + :host { 24 + display: block; 25 + } 26 + 27 + .search-box { 28 + width: 100%; 29 + max-width: 30rem; 30 + margin-bottom: 1.5rem; 31 + padding: 0.75rem 1rem; 32 + border: 2px solid var(--secondary); 33 + border-radius: 4px; 34 + font-size: 1rem; 35 + background: var(--background); 36 + color: var(--text); 37 + } 38 + 39 + .search-box:focus { 40 + outline: none; 41 + border-color: var(--primary); 42 + } 43 + 44 + .loading, 45 + .empty-state { 46 + text-align: center; 47 + padding: 3rem; 48 + color: var(--paynes-gray); 49 + } 50 + 51 + .error { 52 + background: color-mix(in srgb, red 10%, transparent); 53 + border: 1px solid red; 54 + color: red; 55 + padding: 1rem; 56 + border-radius: 4px; 57 + margin-bottom: 1rem; 58 + } 59 + 60 + .transcriptions-grid { 61 + display: grid; 62 + gap: 1rem; 63 + } 64 + 65 + .transcription-card { 66 + background: var(--background); 67 + border: 2px solid var(--secondary); 68 + border-radius: 8px; 69 + padding: 1.5rem; 70 + cursor: pointer; 71 + transition: border-color 0.2s; 72 + } 73 + 74 + .transcription-card:hover { 75 + border-color: var(--primary); 76 + } 77 + 78 + .card-header { 79 + display: flex; 80 + justify-content: space-between; 81 + align-items: flex-start; 82 + margin-bottom: 1rem; 83 + } 84 + 85 + .filename { 86 + font-size: 1.125rem; 87 + font-weight: 600; 88 + color: var(--text); 89 + margin-bottom: 0.5rem; 90 + } 91 + 92 + .status-badge { 93 + padding: 0.5rem 1rem; 94 + border-radius: 4px; 95 + font-size: 0.875rem; 96 + font-weight: 600; 97 + text-transform: uppercase; 98 + } 99 + 100 + .status-completed { 101 + background: color-mix(in srgb, green 10%, transparent); 102 + color: green; 103 + } 104 + 105 + .status-failed { 106 + background: color-mix(in srgb, red 10%, transparent); 107 + color: red; 108 + } 109 + 110 + .status-processing, 111 + .status-transcribing, 112 + .status-uploading, 113 + .status-selected { 114 + background: color-mix(in srgb, var(--accent) 10%, transparent); 115 + color: var(--accent); 116 + } 117 + 118 + .status-pending { 119 + background: color-mix(in srgb, var(--paynes-gray) 10%, transparent); 120 + color: var(--paynes-gray); 121 + } 122 + 123 + .meta-row { 124 + display: flex; 125 + gap: 2rem; 126 + flex-wrap: wrap; 127 + align-items: center; 128 + } 129 + 130 + .user-info { 131 + display: flex; 132 + align-items: center; 133 + gap: 0.5rem; 134 + } 135 + 136 + .user-avatar { 137 + width: 2rem; 138 + height: 2rem; 139 + border-radius: 50%; 140 + } 141 + 142 + .timestamp { 143 + color: var(--paynes-gray); 144 + font-size: 0.875rem; 145 + } 146 + 147 + .delete-btn { 148 + background: transparent; 149 + border: 2px solid #dc2626; 150 + color: #dc2626; 151 + padding: 0.5rem 1rem; 152 + border-radius: 4px; 153 + cursor: pointer; 154 + font-size: 0.875rem; 155 + font-weight: 600; 156 + transition: all 0.2s; 157 + margin-top: 1rem; 158 + } 159 + 160 + .delete-btn:hover:not(:disabled) { 161 + background: #dc2626; 162 + color: var(--white); 163 + } 164 + 165 + .delete-btn:disabled { 166 + opacity: 0.5; 167 + cursor: not-allowed; 168 + } 169 + `; 170 + 171 + override async connectedCallback() { 172 + super.connectedCallback(); 173 + await this.loadTranscriptions(); 174 + } 175 + 176 + private async loadTranscriptions() { 177 + this.isLoading = true; 178 + this.error = null; 179 + 180 + try { 181 + const response = await fetch("/api/admin/transcriptions"); 182 + if (!response.ok) { 183 + throw new Error("Failed to load transcriptions"); 184 + } 185 + 186 + this.transcriptions = await response.json(); 187 + } catch (error) { 188 + console.error("Failed to load transcriptions:", error); 189 + this.error = "Failed to load transcriptions. Please try again."; 190 + } finally { 191 + this.isLoading = false; 192 + } 193 + } 194 + 195 + private async handleDelete(transcriptionId: string) { 196 + if ( 197 + !confirm( 198 + "Are you sure you want to delete this transcription? This cannot be undone.", 199 + ) 200 + ) { 201 + return; 202 + } 203 + 204 + try { 205 + const response = await fetch(`/api/admin/transcriptions/${transcriptionId}`, { 206 + method: "DELETE", 207 + }); 208 + 209 + if (!response.ok) { 210 + throw new Error("Failed to delete transcription"); 211 + } 212 + 213 + await this.loadTranscriptions(); 214 + this.dispatchEvent(new CustomEvent("transcription-deleted")); 215 + } catch (error) { 216 + console.error("Failed to delete transcription:", error); 217 + alert("Failed to delete transcription. Please try again."); 218 + } 219 + } 220 + 221 + private handleCardClick(transcriptionId: string, event: Event) { 222 + // Don't open modal if clicking on delete button 223 + if ((event.target as HTMLElement).closest(".delete-btn")) { 224 + return; 225 + } 226 + this.dispatchEvent( 227 + new CustomEvent("open-transcription", { 228 + detail: { id: transcriptionId }, 229 + }), 230 + ); 231 + } 232 + 233 + private formatTimestamp(timestamp: number): string { 234 + const date = new Date(timestamp * 1000); 235 + return date.toLocaleString(); 236 + } 237 + 238 + private get filteredTranscriptions() { 239 + if (!this.searchQuery) return this.transcriptions; 240 + 241 + const query = this.searchQuery.toLowerCase(); 242 + return this.transcriptions.filter( 243 + (t) => 244 + t.original_filename.toLowerCase().includes(query) || 245 + (t.user_name && t.user_name.toLowerCase().includes(query)) || 246 + t.user_email.toLowerCase().includes(query), 247 + ); 248 + } 249 + 250 + override render() { 251 + if (this.isLoading) { 252 + return html`<div class="loading">Loading transcriptions...</div>`; 253 + } 254 + 255 + if (this.error) { 256 + return html` 257 + <div class="error">${this.error}</div> 258 + <button @click=${this.loadTranscriptions}>Retry</button> 259 + `; 260 + } 261 + 262 + const filtered = this.filteredTranscriptions; 263 + 264 + return html` 265 + <input 266 + type="text" 267 + class="search-box" 268 + placeholder="Search by filename or user..." 269 + .value=${this.searchQuery} 270 + @input=${(e: Event) => { 271 + this.searchQuery = (e.target as HTMLInputElement).value; 272 + }} 273 + /> 274 + 275 + ${ 276 + filtered.length === 0 277 + ? html`<div class="empty-state">No transcriptions found</div>` 278 + : html` 279 + <div class="transcriptions-grid"> 280 + ${filtered.map( 281 + (t) => html` 282 + <div class="transcription-card" @click=${(e: Event) => this.handleCardClick(t.id, e)}> 283 + <div class="card-header"> 284 + <div class="filename">${t.original_filename}</div> 285 + <span class="status-badge status-${t.status}">${t.status}</span> 286 + </div> 287 + 288 + <div class="meta-row"> 289 + <div class="user-info"> 290 + <img 291 + src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${t.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 292 + alt="Avatar" 293 + class="user-avatar" 294 + /> 295 + <span>${t.user_name || t.user_email}</span> 296 + </div> 297 + <span class="timestamp">${this.formatTimestamp(t.created_at)}</span> 298 + </div> 299 + 300 + ${ 301 + t.error_message 302 + ? html`<div class="error" style="margin-top: 1rem;">${t.error_message}</div>` 303 + : "" 304 + } 305 + 306 + <button class="delete-btn" @click=${() => this.handleDelete(t.id)}> 307 + Delete 308 + </button> 309 + </div> 310 + `, 311 + )} 312 + </div> 313 + ` 314 + } 315 + `; 316 + } 317 + }
+440
src/components/admin-users.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + 4 + interface User { 5 + id: number; 6 + email: string; 7 + name: string | null; 8 + avatar: string; 9 + role: "user" | "admin"; 10 + transcription_count: number; 11 + last_login: number | null; 12 + created_at: number; 13 + } 14 + 15 + @customElement("admin-users") 16 + export class AdminUsers extends LitElement { 17 + @state() users: User[] = []; 18 + @state() searchQuery = ""; 19 + @state() isLoading = true; 20 + @state() error: string | null = null; 21 + @state() currentUserEmail: string | null = null; 22 + 23 + static override styles = css` 24 + :host { 25 + display: block; 26 + } 27 + 28 + .search-box { 29 + width: 100%; 30 + max-width: 30rem; 31 + margin-bottom: 1.5rem; 32 + padding: 0.75rem 1rem; 33 + border: 2px solid var(--secondary); 34 + border-radius: 4px; 35 + font-size: 1rem; 36 + background: var(--background); 37 + color: var(--text); 38 + } 39 + 40 + .search-box:focus { 41 + outline: none; 42 + border-color: var(--primary); 43 + } 44 + 45 + .loading, 46 + .empty-state { 47 + text-align: center; 48 + padding: 3rem; 49 + color: var(--paynes-gray); 50 + } 51 + 52 + .error { 53 + background: color-mix(in srgb, red 10%, transparent); 54 + border: 1px solid red; 55 + color: red; 56 + padding: 1rem; 57 + border-radius: 4px; 58 + margin-bottom: 1rem; 59 + } 60 + 61 + .users-grid { 62 + display: grid; 63 + gap: 1rem; 64 + } 65 + 66 + .user-card { 67 + background: var(--background); 68 + border: 2px solid var(--secondary); 69 + border-radius: 8px; 70 + padding: 1.5rem; 71 + cursor: pointer; 72 + transition: border-color 0.2s; 73 + } 74 + 75 + .user-card:hover { 76 + border-color: var(--primary); 77 + } 78 + 79 + .card-header { 80 + display: flex; 81 + justify-content: space-between; 82 + align-items: flex-start; 83 + margin-bottom: 1rem; 84 + } 85 + 86 + .user-info { 87 + display: flex; 88 + align-items: center; 89 + gap: 1rem; 90 + } 91 + 92 + .user-avatar { 93 + width: 3rem; 94 + height: 3rem; 95 + border-radius: 50%; 96 + } 97 + 98 + .user-details { 99 + flex: 1; 100 + } 101 + 102 + .user-name { 103 + font-size: 1.125rem; 104 + font-weight: 600; 105 + color: var(--text); 106 + margin-bottom: 0.25rem; 107 + } 108 + 109 + .user-email { 110 + font-size: 0.875rem; 111 + color: var(--paynes-gray); 112 + } 113 + 114 + .admin-badge { 115 + background: var(--accent); 116 + color: var(--white); 117 + padding: 0.5rem 1rem; 118 + border-radius: 4px; 119 + font-size: 0.75rem; 120 + font-weight: 600; 121 + text-transform: uppercase; 122 + } 123 + 124 + .meta-row { 125 + display: flex; 126 + gap: 2rem; 127 + flex-wrap: wrap; 128 + margin-bottom: 1rem; 129 + } 130 + 131 + .meta-item { 132 + display: flex; 133 + flex-direction: column; 134 + gap: 0.25rem; 135 + } 136 + 137 + .meta-label { 138 + font-size: 0.75rem; 139 + font-weight: 600; 140 + text-transform: uppercase; 141 + color: var(--paynes-gray); 142 + letter-spacing: 0.05em; 143 + } 144 + 145 + .meta-value { 146 + font-size: 0.875rem; 147 + color: var(--text); 148 + } 149 + 150 + .timestamp { 151 + color: var(--paynes-gray); 152 + font-size: 0.875rem; 153 + } 154 + 155 + .actions { 156 + display: flex; 157 + gap: 0.75rem; 158 + align-items: center; 159 + flex-wrap: wrap; 160 + } 161 + 162 + .role-select { 163 + padding: 0.5rem 0.75rem; 164 + border: 2px solid var(--secondary); 165 + border-radius: 4px; 166 + font-size: 0.875rem; 167 + background: var(--background); 168 + color: var(--text); 169 + cursor: pointer; 170 + font-weight: 600; 171 + } 172 + 173 + .role-select:focus { 174 + outline: none; 175 + border-color: var(--primary); 176 + } 177 + 178 + .delete-btn { 179 + background: transparent; 180 + border: 2px solid #dc2626; 181 + color: #dc2626; 182 + padding: 0.5rem 1rem; 183 + border-radius: 4px; 184 + cursor: pointer; 185 + font-size: 0.875rem; 186 + font-weight: 600; 187 + transition: all 0.2s; 188 + } 189 + 190 + .delete-btn:hover:not(:disabled) { 191 + background: #dc2626; 192 + color: var(--white); 193 + } 194 + 195 + .delete-btn:disabled { 196 + opacity: 0.5; 197 + cursor: not-allowed; 198 + } 199 + `; 200 + 201 + override async connectedCallback() { 202 + super.connectedCallback(); 203 + await this.getCurrentUser(); 204 + await this.loadUsers(); 205 + } 206 + 207 + private async getCurrentUser() { 208 + try { 209 + const response = await fetch("/api/auth/me"); 210 + if (response.ok) { 211 + const user = await response.json(); 212 + this.currentUserEmail = user.email; 213 + } 214 + } catch (error) { 215 + console.error("Failed to get current user:", error); 216 + } 217 + } 218 + 219 + private async loadUsers() { 220 + this.isLoading = true; 221 + this.error = null; 222 + 223 + try { 224 + const response = await fetch("/api/admin/users"); 225 + if (!response.ok) { 226 + throw new Error("Failed to load users"); 227 + } 228 + 229 + this.users = await response.json(); 230 + } catch (error) { 231 + console.error("Failed to load users:", error); 232 + this.error = "Failed to load users. Please try again."; 233 + } finally { 234 + this.isLoading = false; 235 + } 236 + } 237 + 238 + private async handleRoleChange(userId: number, email: string, newRole: "user" | "admin", oldRole: "user" | "admin", event: Event) { 239 + const select = event.target as HTMLSelectElement; 240 + 241 + const isDemotingSelf = 242 + email === this.currentUserEmail && 243 + oldRole === "admin" && 244 + newRole === "user"; 245 + 246 + if (isDemotingSelf) { 247 + if ( 248 + !confirm( 249 + "⚠️ WARNING: You are about to demote yourself from admin to user. You will lose access to this admin panel immediately. Are you sure?", 250 + ) 251 + ) { 252 + select.value = oldRole; 253 + return; 254 + } 255 + 256 + if ( 257 + !confirm( 258 + "⚠️ FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?", 259 + ) 260 + ) { 261 + select.value = oldRole; 262 + return; 263 + } 264 + } else { 265 + if (!confirm(`Change user role to ${newRole}?`)) { 266 + select.value = oldRole; 267 + return; 268 + } 269 + } 270 + 271 + try { 272 + const response = await fetch(`/api/admin/users/${userId}/role`, { 273 + method: "PUT", 274 + headers: { "Content-Type": "application/json" }, 275 + body: JSON.stringify({ role: newRole }), 276 + }); 277 + 278 + if (!response.ok) { 279 + throw new Error("Failed to update role"); 280 + } 281 + 282 + if (isDemotingSelf) { 283 + window.location.href = "/"; 284 + } else { 285 + await this.loadUsers(); 286 + } 287 + } catch (error) { 288 + console.error("Failed to update role:", error); 289 + alert("Failed to update user role"); 290 + select.value = oldRole; 291 + } 292 + } 293 + 294 + private async handleDelete(userId: number, email: string) { 295 + if ( 296 + !confirm( 297 + `Are you sure you want to delete user ${email}? This will delete all their transcriptions and cannot be undone.`, 298 + ) 299 + ) { 300 + return; 301 + } 302 + 303 + try { 304 + const response = await fetch(`/api/admin/users/${userId}`, { 305 + method: "DELETE", 306 + }); 307 + 308 + if (!response.ok) { 309 + throw new Error("Failed to delete user"); 310 + } 311 + 312 + await this.loadUsers(); 313 + this.dispatchEvent(new CustomEvent("user-deleted")); 314 + } catch (error) { 315 + console.error("Failed to delete user:", error); 316 + alert("Failed to delete user. Please try again."); 317 + } 318 + } 319 + 320 + private handleCardClick(userId: number, event: Event) { 321 + // Don't open modal if clicking on delete button or role select 322 + if ( 323 + (event.target as HTMLElement).closest(".delete-btn") || 324 + (event.target as HTMLElement).closest(".role-select") 325 + ) { 326 + return; 327 + } 328 + this.dispatchEvent( 329 + new CustomEvent("open-user", { 330 + detail: { id: userId }, 331 + }), 332 + ); 333 + } 334 + 335 + private formatTimestamp(timestamp: number | null): string { 336 + if (!timestamp) return "Never"; 337 + const date = new Date(timestamp * 1000); 338 + return date.toLocaleString(); 339 + } 340 + 341 + private get filteredUsers() { 342 + if (!this.searchQuery) return this.users; 343 + 344 + const query = this.searchQuery.toLowerCase(); 345 + return this.users.filter( 346 + (u) => 347 + u.email.toLowerCase().includes(query) || 348 + (u.name && u.name.toLowerCase().includes(query)), 349 + ); 350 + } 351 + 352 + override render() { 353 + if (this.isLoading) { 354 + return html`<div class="loading">Loading users...</div>`; 355 + } 356 + 357 + if (this.error) { 358 + return html` 359 + <div class="error">${this.error}</div> 360 + <button @click=${this.loadUsers}>Retry</button> 361 + `; 362 + } 363 + 364 + const filtered = this.filteredUsers; 365 + 366 + return html` 367 + <input 368 + type="text" 369 + class="search-box" 370 + placeholder="Search by name or email..." 371 + .value=${this.searchQuery} 372 + @input=${(e: Event) => { 373 + this.searchQuery = (e.target as HTMLInputElement).value; 374 + }} 375 + /> 376 + 377 + ${ 378 + filtered.length === 0 379 + ? html`<div class="empty-state">No users found</div>` 380 + : html` 381 + <div class="users-grid"> 382 + ${filtered.map( 383 + (u) => html` 384 + <div class="user-card" @click=${(e: Event) => this.handleCardClick(u.id, e)}> 385 + <div class="card-header"> 386 + <div class="user-info"> 387 + <img 388 + src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 389 + alt="Avatar" 390 + class="user-avatar" 391 + /> 392 + <div class="user-details"> 393 + <div class="user-name">${u.name || "Anonymous"}</div> 394 + <div class="user-email">${u.email}</div> 395 + </div> 396 + </div> 397 + ${u.role === "admin" ? html`<span class="admin-badge">Admin</span>` : ""} 398 + </div> 399 + 400 + <div class="meta-row"> 401 + <div class="meta-item"> 402 + <div class="meta-label">Transcriptions</div> 403 + <div class="meta-value">${u.transcription_count}</div> 404 + </div> 405 + <div class="meta-item"> 406 + <div class="meta-label">Last Login</div> 407 + <div class="meta-value timestamp"> 408 + ${this.formatTimestamp(u.last_login)} 409 + </div> 410 + </div> 411 + <div class="meta-item"> 412 + <div class="meta-label">Joined</div> 413 + <div class="meta-value timestamp"> 414 + ${this.formatTimestamp(u.created_at)} 415 + </div> 416 + </div> 417 + </div> 418 + 419 + <div class="actions"> 420 + <select 421 + class="role-select" 422 + .value=${u.role} 423 + @change=${(e: Event) => this.handleRoleChange(u.id, u.email, (e.target as HTMLSelectElement).value as "user" | "admin", u.role, e)} 424 + > 425 + <option value="user">User</option> 426 + <option value="admin">Admin</option> 427 + </select> 428 + <button class="delete-btn" @click=${() => this.handleDelete(u.id, u.email)}> 429 + Delete User 430 + </button> 431 + </div> 432 + </div> 433 + `, 434 + )} 435 + </div> 436 + ` 437 + } 438 + `; 439 + } 440 + }
+34 -624
src/pages/admin.html
··· 72 72 display: block; 73 73 } 74 74 75 - table { 76 - width: 100%; 77 - border-collapse: collapse; 78 - background: var(--background); 79 - border: 2px solid var(--secondary); 80 - border-radius: 8px; 81 - overflow: hidden; 82 - } 83 - 84 - thead { 85 - background: var(--primary); 86 - color: white; 87 - } 88 - 89 - th { 90 - padding: 1rem; 91 - text-align: left; 92 - font-weight: 600; 93 - } 94 - 95 - td { 96 - padding: 1rem; 97 - border-top: 1px solid var(--secondary); 98 - color: var(--text); 99 - } 100 - 101 - tr:hover { 102 - background: rgba(0, 0, 0, 0.02); 103 - } 104 - 105 - .status-badge { 106 - display: inline-block; 107 - padding: 0.25rem 0.75rem; 108 - border-radius: 4px; 109 - font-size: 0.875rem; 110 - font-weight: 500; 111 - } 112 - 113 - .status-completed { 114 - background: #dcfce7; 115 - color: #166534; 116 - } 117 - 118 - .status-processing, 119 - .status-uploading { 120 - background: #fef3c7; 121 - color: #92400e; 122 - } 123 - 124 - .status-failed { 125 - background: #fee2e2; 126 - color: #991b1b; 127 - } 128 - 129 - .status-pending { 130 - background: #e0e7ff; 131 - color: #3730a3; 132 - } 133 - 134 - .admin-badge { 135 - background: var(--accent); 136 - color: white; 137 - padding: 0.25rem 0.5rem; 138 - border-radius: 4px; 139 - font-size: 0.75rem; 140 - font-weight: 600; 141 - margin-left: 0.5rem; 142 - } 143 - 144 - .user-info { 145 - display: flex; 146 - align-items: center; 147 - gap: 0.5rem; 148 - } 149 - 150 - .user-avatar { 151 - width: 2rem; 152 - height: 2rem; 153 - border-radius: 50%; 154 - } 155 - 156 75 .empty-state { 157 76 text-align: center; 158 77 padding: 3rem; ··· 206 125 opacity: 0.6; 207 126 font-size: 0.875rem; 208 127 } 209 - 210 - .delete-btn { 211 - background: transparent; 212 - border: 2px solid #dc2626; 213 - color: #dc2626; 214 - padding: 0.25rem 0.75rem; 215 - border-radius: 4px; 216 - cursor: pointer; 217 - font-size: 0.875rem; 218 - font-weight: 500; 219 - font-family: inherit; 220 - transition: all 0.2s; 221 - } 222 - 223 - .delete-btn:hover { 224 - background: #dc2626; 225 - color: white; 226 - } 227 - 228 - .delete-btn:disabled { 229 - opacity: 0.5; 230 - cursor: not-allowed; 231 - } 232 - 233 - .actions { 234 - display: flex; 235 - gap: 0.5rem; 236 - } 237 - 238 - .role-select { 239 - padding: 0.25rem 0.5rem; 240 - border: 2px solid var(--secondary); 241 - border-radius: 4px; 242 - font-size: 0.875rem; 243 - font-family: inherit; 244 - background: var(--background); 245 - color: var(--text); 246 - cursor: pointer; 247 - } 248 - 249 - .role-select:focus { 250 - outline: none; 251 - border-color: var(--primary); 252 - } 253 - 254 - .delete-user-btn { 255 - background: transparent; 256 - border: 2px solid #dc2626; 257 - color: #dc2626; 258 - padding: 0.25rem 0.75rem; 259 - border-radius: 4px; 260 - cursor: pointer; 261 - font-size: 0.875rem; 262 - font-weight: 500; 263 - font-family: inherit; 264 - transition: all 0.2s; 265 - } 266 - 267 - .delete-user-btn:hover { 268 - background: #dc2626; 269 - color: white; 270 - } 271 - 272 - .delete-user-btn:disabled { 273 - opacity: 0.5; 274 - cursor: not-allowed; 275 - } 276 - 277 - .users-table tbody tr { 278 - cursor: pointer; 279 - } 280 - 281 - .users-table tbody tr:hover { 282 - background: rgba(0, 0, 0, 0.04); 283 - } 284 - 285 - .transcriptions-table tbody tr { 286 - cursor: pointer; 287 - } 288 - 289 - .transcriptions-table tbody tr:hover { 290 - background: rgba(0, 0, 0, 0.04); 291 - } 292 - 293 - .search { 294 - width: 100%; 295 - max-width: 30rem; 296 - margin-bottom: 1rem; 297 - padding: 0.5rem 0.75rem; 298 - border: 2px solid var(--secondary); 299 - border-radius: 4px; 300 - font-size: 1rem; 301 - font-family: inherit; 302 - background: var(--background); 303 - color: var(--text); 304 - } 305 - 306 - .search:focus { 307 - outline: none; 308 - border-color: var(--primary); 309 - } 310 - 311 - th.sortable { 312 - cursor: pointer; 313 - user-select: none; 314 - position: relative; 315 - } 316 - 317 - th.sortable:hover { 318 - background: var(--gunmetal); 319 - } 320 - 321 - th.sortable::after { 322 - content: ''; 323 - margin-left: 0.5rem; 324 - opacity: 0.3; 325 - } 326 - 327 - th.sortable.asc::after { 328 - content: '▲'; 329 - opacity: 1; 330 - } 331 - 332 - th.sortable.desc::after { 333 - content: '▼'; 334 - opacity: 1; 335 - } 336 128 </style> 337 129 </head> 338 130 ··· 386 178 <div id="transcriptions-tab" class="tab-content"> 387 179 <div class="section"> 388 180 <h2 class="section-title">All Transcriptions</h2> 389 - <input type="text" id="transcript-search" class="search" placeholder="Search by filename or user..." /> 390 - <div id="transcriptions-table" class="transcriptions-table"></div> 181 + <admin-transcriptions id="transcriptions-component"></admin-transcriptions> 391 182 </div> 392 183 </div> 393 184 394 185 <div id="users-tab" class="tab-content"> 395 186 <div class="section"> 396 187 <h2 class="section-title">All Users</h2> 397 - <input type="text" id="user-search" class="search" placeholder="Search by name or email..." /> 398 - <div id="users-table" class="users-table"></div> 188 + <admin-users id="users-component"></admin-users> 399 189 </div> 400 190 </div> 401 191 </div> ··· 406 196 407 197 <script type="module" src="../components/auth.ts"></script> 408 198 <script type="module" src="../components/admin-pending-recordings.ts"></script> 199 + <script type="module" src="../components/admin-transcriptions.ts"></script> 200 + <script type="module" src="../components/admin-users.ts"></script> 409 201 <script type="module" src="../components/user-modal.ts"></script> 410 202 <script type="module" src="../components/transcript-view-modal.ts"></script> 411 203 <script type="module"> 204 + const transcriptionsComponent = document.getElementById('transcriptions-component'); 205 + const usersComponent = document.getElementById('users-component'); 206 + const userModal = document.getElementById('user-modal'); 207 + const transcriptModal = document.getElementById('transcript-modal'); 412 208 const errorMessage = document.getElementById('error-message'); 413 209 const loading = document.getElementById('loading'); 414 210 const content = document.getElementById('content'); 415 - const transcriptionsTable = document.getElementById('transcriptions-table'); 416 - const usersTable = document.getElementById('users-table'); 417 - const userModal = document.getElementById('user-modal'); 418 - const transcriptModal = document.getElementById('transcript-modal'); 419 - 420 - let currentUserEmail = null; 421 - let allUsers = []; 422 - let allTranscriptions = []; 423 - let userSortKey = 'created_at'; 424 - let userSortDirection = 'desc'; 425 - let userSearchTerm = ''; 426 - let transcriptSortKey = 'created_at'; 427 - let transcriptSortDirection = 'desc'; 428 - let transcriptSearchTerm = ''; 429 - 430 - // Get current user info 431 - async function getCurrentUser() { 432 - try { 433 - const res = await fetch('/api/auth/me'); 434 - if (res.ok) { 435 - const user = await res.json(); 436 - currentUserEmail = user.email; 437 - } 438 - } catch { 439 - // Ignore errors 440 - } 441 - } 442 - 443 - function showError(message) { 444 - errorMessage.textContent = message; 445 - errorMessage.style.display = 'block'; 446 - loading.style.display = 'none'; 447 - } 448 - 449 - function formatTimestamp(timestamp) { 450 - const date = new Date(timestamp * 1000); 451 - return date.toLocaleString(); 452 - } 453 211 454 212 // Modal functions 455 213 function openUserModal(userId) { ··· 472 230 transcriptModal.transcriptId = null; 473 231 } 474 232 475 - // Listen for modal close and user update events 233 + // Listen for component events 234 + transcriptionsComponent.addEventListener('open-transcription', (e) => { 235 + openTranscriptModal(e.detail.id); 236 + }); 237 + 238 + usersComponent.addEventListener('open-user', (e) => { 239 + openUserModal(e.detail.id); 240 + }); 241 + 242 + // Listen for modal close events 476 243 userModal.addEventListener('close', closeUserModal); 477 - userModal.addEventListener('user-updated', () => loadData()); 244 + userModal.addEventListener('user-updated', async () => { 245 + await loadStats(); 246 + }); 478 247 userModal.addEventListener('click', (e) => { 479 - if (e.target === userModal) { 480 - closeUserModal(); 481 - } 248 + if (e.target === userModal) closeUserModal(); 482 249 }); 483 250 484 - // Listen for transcript modal events 485 251 transcriptModal.addEventListener('close', closeTranscriptModal); 486 - transcriptModal.addEventListener('transcript-deleted', () => loadData()); 252 + transcriptModal.addEventListener('transcript-deleted', async () => { 253 + await loadStats(); 254 + }); 487 255 transcriptModal.addEventListener('click', (e) => { 488 - if (e.target === transcriptModal) { 489 - closeTranscriptModal(); 490 - } 256 + if (e.target === transcriptModal) closeTranscriptModal(); 491 257 }); 492 258 493 - 494 - function renderTranscriptions(transcriptions) { 495 - allTranscriptions = transcriptions; 496 - 497 - // Filter transcriptions based on search term 498 - const filteredTranscriptions = transcriptions.filter(t => { 499 - if (!transcriptSearchTerm) return true; 500 - const term = transcriptSearchTerm.toLowerCase(); 501 - const filename = (t.original_filename || '').toLowerCase(); 502 - const userName = (t.user_name || '').toLowerCase(); 503 - const userEmail = (t.user_email || '').toLowerCase(); 504 - return filename.includes(term) || userName.includes(term) || userEmail.includes(term); 505 - }); 506 - 507 - // Sort transcriptions 508 - filteredTranscriptions.sort((a, b) => { 509 - let aVal = a[transcriptSortKey]; 510 - let bVal = b[transcriptSortKey]; 511 - 512 - // Handle null values 513 - if (aVal === null || aVal === undefined) aVal = ''; 514 - if (bVal === null || bVal === undefined) bVal = ''; 515 - 516 - let comparison = 0; 517 - if (typeof aVal === 'string' && typeof bVal === 'string') { 518 - comparison = aVal.localeCompare(bVal); 519 - } else if (typeof aVal === 'number' && typeof bVal === 'number') { 520 - comparison = aVal - bVal; 521 - } else { 522 - comparison = String(aVal).localeCompare(String(bVal)); 523 - } 524 - 525 - return transcriptSortDirection === 'asc' ? comparison : -comparison; 526 - }); 527 - 528 - if (filteredTranscriptions.length === 0) { 529 - transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions found</div>'; 530 - return; 531 - } 532 - 533 - const failed = transcriptions.filter(t => t.status === 'failed'); 534 - document.getElementById('failed-transcriptions').textContent = failed.length; 535 - 536 - const table = document.createElement('table'); 537 - table.innerHTML = ` 538 - <thead> 539 - <tr> 540 - <th class="sortable ${transcriptSortKey === 'original_filename' ? transcriptSortDirection : ''}" data-sort="original_filename">File Name</th> 541 - <th>User</th> 542 - <th class="sortable ${transcriptSortKey === 'status' ? transcriptSortDirection : ''}" data-sort="status">Status</th> 543 - <th class="sortable ${transcriptSortKey === 'created_at' ? transcriptSortDirection : ''}" data-sort="created_at">Created At</th> 544 - <th>Actions</th> 545 - </tr> 546 - </thead> 547 - <tbody> 548 - ${filteredTranscriptions.map(t => ` 549 - <tr data-id="${t.id}"> 550 - <td>${t.original_filename}</td> 551 - <td> 552 - <div class="user-info"> 553 - <img 554 - src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${t.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 555 - alt="Avatar" 556 - class="user-avatar" 557 - /> 558 - <span>${t.user_name || t.user_email}</span> 559 - </div> 560 - </td> 561 - <td><span class="status-badge status-${t.status}">${t.status}</span></td> 562 - <td class="timestamp">${formatTimestamp(t.created_at)}</td> 563 - <td> 564 - <button class="delete-btn" data-id="${t.id}">Delete</button> 565 - </td> 566 - </tr> 567 - `).join('')} 568 - </tbody> 569 - `; 570 - transcriptionsTable.innerHTML = ''; 571 - transcriptionsTable.appendChild(table); 572 - 573 - // Add sort event listeners 574 - table.querySelectorAll('th.sortable').forEach(th => { 575 - th.addEventListener('click', () => { 576 - const sortKey = th.dataset.sort; 577 - if (transcriptSortKey === sortKey) { 578 - transcriptSortDirection = transcriptSortDirection === 'asc' ? 'desc' : 'asc'; 579 - } else { 580 - transcriptSortKey = sortKey; 581 - transcriptSortDirection = 'asc'; 582 - } 583 - renderTranscriptions(allTranscriptions); 584 - }); 585 - }); 586 - 587 - // Add delete event listeners 588 - table.querySelectorAll('.delete-btn').forEach(btn => { 589 - btn.addEventListener('click', async (e) => { 590 - e.stopPropagation(); // Prevent row click 591 - const button = e.target; 592 - const id = button.dataset.id; 593 - 594 - if (!confirm('Are you sure you want to delete this transcription? This cannot be undone.')) { 595 - return; 596 - } 597 - 598 - button.disabled = true; 599 - button.textContent = 'Deleting...'; 600 - 601 - try { 602 - const res = await fetch(`/api/admin/transcriptions/${id}`, { 603 - method: 'DELETE' 604 - }); 605 - 606 - if (!res.ok) { 607 - throw new Error('Failed to delete'); 608 - } 609 - 610 - // Reload data 611 - await loadData(); 612 - } catch { 613 - alert('Failed to delete transcription'); 614 - button.disabled = false; 615 - button.textContent = 'Delete'; 616 - } 617 - }); 618 - }); 619 - 620 - // Add click event to table rows to open modal 621 - table.querySelectorAll('tbody tr').forEach(row => { 622 - row.addEventListener('click', (e) => { 623 - // Don't open modal if clicking on delete button 624 - if (e.target.closest('.delete-btn')) { 625 - return; 626 - } 627 - 628 - const transcriptId = row.dataset.id; 629 - openTranscriptModal(transcriptId); 630 - }); 631 - }); 632 - } 633 - 634 - function renderUsers(users) { 635 - allUsers = users; 636 - 637 - // Filter users based on search term 638 - const filteredUsers = users.filter(u => { 639 - if (!userSearchTerm) return true; 640 - const term = userSearchTerm.toLowerCase(); 641 - const name = (u.name || '').toLowerCase(); 642 - const email = u.email.toLowerCase(); 643 - return name.includes(term) || email.includes(term); 644 - }); 645 - 646 - // Sort users 647 - filteredUsers.sort((a, b) => { 648 - let aVal = a[userSortKey]; 649 - let bVal = b[userSortKey]; 650 - 651 - // Handle null values 652 - if (aVal === null || aVal === undefined) aVal = ''; 653 - if (bVal === null || bVal === undefined) bVal = ''; 654 - 655 - let comparison = 0; 656 - if (typeof aVal === 'string' && typeof bVal === 'string') { 657 - comparison = aVal.localeCompare(bVal); 658 - } else if (typeof aVal === 'number' && typeof bVal === 'number') { 659 - comparison = aVal - bVal; 660 - } else { 661 - comparison = String(aVal).localeCompare(String(bVal)); 662 - } 663 - 664 - return userSortDirection === 'asc' ? comparison : -comparison; 665 - }); 666 - 667 - if (filteredUsers.length === 0) { 668 - usersTable.innerHTML = '<div class="empty-state">No users found</div>'; 669 - return; 670 - } 671 - 672 - const table = document.createElement('table'); 673 - table.innerHTML = ` 674 - <thead> 675 - <tr> 676 - <th class="sortable ${userSortKey === 'name' ? userSortDirection : ''}" data-sort="name">User</th> 677 - <th class="sortable ${userSortKey === 'email' ? userSortDirection : ''}" data-sort="email">Email</th> 678 - <th class="sortable ${userSortKey === 'role' ? userSortDirection : ''}" data-sort="role">Role</th> 679 - <th class="sortable ${userSortKey === 'transcription_count' ? userSortDirection : ''}" data-sort="transcription_count">Transcriptions</th> 680 - <th class="sortable ${userSortKey === 'last_login' ? userSortDirection : ''}" data-sort="last_login">Last Login</th> 681 - <th class="sortable ${userSortKey === 'created_at' ? userSortDirection : ''}" data-sort="created_at">Joined</th> 682 - <th>Actions</th> 683 - </tr> 684 - </thead> 685 - <tbody> 686 - ${filteredUsers.map(u => ` 687 - <tr> 688 - <td> 689 - <div class="user-info"> 690 - <img 691 - src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 692 - alt="Avatar" 693 - class="user-avatar" 694 - /> 695 - <span>${u.name || 'Anonymous'}</span> 696 - ${u.role === 'admin' ? '<span class="admin-badge">ADMIN</span>' : ''} 697 - </div> 698 - </td> 699 - <td>${u.email}</td> 700 - <td> 701 - <select class="role-select" data-user-id="${u.id}" data-current-role="${u.role}"> 702 - <option value="user" ${u.role === 'user' ? 'selected' : ''}>User</option> 703 - <option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option> 704 - </select> 705 - </td> 706 - <td>${u.transcription_count}</td> 707 - <td class="timestamp">${u.last_login ? formatTimestamp(u.last_login) : 'Never'}</td> 708 - <td class="timestamp">${formatTimestamp(u.created_at)}</td> 709 - <td> 710 - <div class="actions"> 711 - <button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button> 712 - </div> 713 - </td> 714 - </tr> 715 - `).join('')} 716 - </tbody> 717 - `; 718 - usersTable.innerHTML = ''; 719 - usersTable.appendChild(table); 720 - 721 - // Add sort event listeners 722 - table.querySelectorAll('th.sortable').forEach(th => { 723 - th.addEventListener('click', () => { 724 - const sortKey = th.dataset.sort; 725 - if (userSortKey === sortKey) { 726 - userSortDirection = userSortDirection === 'asc' ? 'desc' : 'asc'; 727 - } else { 728 - userSortKey = sortKey; 729 - userSortDirection = 'asc'; 730 - } 731 - renderUsers(allUsers); 732 - }); 733 - }); 734 - 735 - // Add role change event listeners 736 - table.querySelectorAll('.role-select').forEach(select => { 737 - select.addEventListener('change', async (e) => { 738 - const selectEl = e.target; 739 - const userId = selectEl.dataset.userId; 740 - const newRole = selectEl.value; 741 - const oldRole = selectEl.dataset.currentRole; 742 - 743 - // Get user email from the row 744 - const row = selectEl.closest('tr'); 745 - const userEmail = row.querySelector('td:nth-child(2)').textContent; 746 - 747 - // Check if user is demoting themselves 748 - const isDemotingSelf = userEmail === currentUserEmail && oldRole === 'admin' && newRole === 'user'; 749 - 750 - if (isDemotingSelf) { 751 - if (!confirm('⚠️ WARNING: You are about to demote yourself from admin to user. You will lose access to this admin panel immediately. Are you sure?')) { 752 - selectEl.value = oldRole; 753 - return; 754 - } 755 - 756 - if (!confirm('⚠️ FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?')) { 757 - selectEl.value = oldRole; 758 - return; 759 - } 760 - } else { 761 - if (!confirm(`Change user role to ${newRole}?`)) { 762 - selectEl.value = oldRole; 763 - return; 764 - } 765 - } 766 - 767 - try { 768 - const res = await fetch(`/api/admin/users/${userId}/role`, { 769 - method: 'PUT', 770 - headers: {'Content-Type': 'application/json'}, 771 - body: JSON.stringify({role: newRole}) 772 - }); 773 - 774 - if (!res.ok) { 775 - throw new Error('Failed to update role'); 776 - } 777 - 778 - selectEl.dataset.currentRole = newRole; 779 - 780 - // If demoting self, redirect to home 781 - if (isDemotingSelf) { 782 - window.location.href = '/'; 783 - } else { 784 - await loadData(); 785 - } 786 - } catch { 787 - alert('Failed to update user role'); 788 - selectEl.value = oldRole; 789 - } 790 - }); 791 - }); 792 - 793 - // Add delete user event listeners 794 - table.querySelectorAll('.delete-user-btn').forEach(btn => { 795 - btn.addEventListener('click', async (e) => { 796 - const button = e.target; 797 - const userId = button.dataset.userId; 798 - const userEmail = button.dataset.userEmail; 799 - 800 - if (!confirm(`Are you sure you want to delete user ${userEmail}? This will delete all their transcriptions and cannot be undone.`)) { 801 - return; 802 - } 803 - 804 - button.disabled = true; 805 - button.textContent = 'Deleting...'; 806 - 807 - try { 808 - const res = await fetch(`/api/admin/users/${userId}`, { 809 - method: 'DELETE' 810 - }); 811 - 812 - if (!res.ok) { 813 - throw new Error('Failed to delete user'); 814 - } 815 - 816 - await loadData(); 817 - } catch { 818 - alert('Failed to delete user'); 819 - button.disabled = false; 820 - button.textContent = 'Delete'; 821 - } 822 - }); 823 - }); 824 - 825 - // Add click event to table rows to open modal 826 - table.querySelectorAll('tbody tr').forEach(row => { 827 - row.addEventListener('click', (e) => { 828 - // Don't open modal if clicking on delete button or role select 829 - if (e.target.closest('.delete-user-btn') || e.target.closest('.role-select')) { 830 - return; 831 - } 832 - 833 - const userId = row.querySelector('.delete-user-btn').dataset.userId; 834 - openUserModal(userId); 835 - }); 836 - }); 837 - } 838 - 839 - async function loadData() { 259 + async function loadStats() { 840 260 try { 841 261 const [transcriptionsRes, usersRes] = await Promise.all([ 842 262 fetch('/api/admin/transcriptions'), ··· 856 276 857 277 document.getElementById('total-users').textContent = users.length; 858 278 document.getElementById('total-transcriptions').textContent = transcriptions.length; 859 - 860 - renderTranscriptions(transcriptions); 861 - renderUsers(users); 279 + 280 + const failed = transcriptions.filter(t => t.status === 'failed'); 281 + document.getElementById('failed-transcriptions').textContent = failed.length; 862 282 863 283 loading.style.display = 'none'; 864 284 content.style.display = 'block'; 865 285 } catch (error) { 866 - showError(error.message); 286 + errorMessage.textContent = error.message; 287 + errorMessage.style.display = 'block'; 288 + loading.style.display = 'none'; 867 289 } 868 290 } 869 291 ··· 884 306 }); 885 307 }); 886 308 887 - // User search 888 - document.getElementById('user-search').addEventListener('input', (e) => { 889 - userSearchTerm = e.target.value.trim(); 890 - renderUsers(allUsers); 891 - }); 892 - 893 - // Transcript search 894 - document.getElementById('transcript-search').addEventListener('input', (e) => { 895 - transcriptSearchTerm = e.target.value.trim(); 896 - renderTranscriptions(allTranscriptions); 897 - }); 898 - 899 309 // Initialize 900 - getCurrentUser().then(() => loadData()); 310 + loadStats(); 901 311 </script> 902 312 </body> 903 313