馃 distributed transcription service thistle.dunkirk.sh
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3 4interface 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") 16export 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 .error-banner { 28 background: #fecaca; 29 border: 2px solid rgba(220, 38, 38, 0.8); 30 border-radius: 6px; 31 padding: 1rem; 32 margin-bottom: 1.5rem; 33 color: #dc2626; 34 font-weight: 500; 35 } 36 37 .search-box { 38 width: 100%; 39 max-width: 30rem; 40 margin-bottom: 1.5rem; 41 padding: 0.75rem 1rem; 42 border: 2px solid var(--secondary); 43 border-radius: 4px; 44 font-size: 1rem; 45 background: var(--background); 46 color: var(--text); 47 } 48 49 .search-box:focus { 50 outline: none; 51 border-color: var(--primary); 52 } 53 54 .loading, 55 .empty-state { 56 text-align: center; 57 padding: 3rem; 58 color: var(--paynes-gray); 59 } 60 61 .error { 62 background: color-mix(in srgb, red 10%, transparent); 63 border: 1px solid red; 64 color: red; 65 padding: 1rem; 66 border-radius: 4px; 67 margin-bottom: 1rem; 68 } 69 70 .transcriptions-grid { 71 display: grid; 72 gap: 1rem; 73 } 74 75 .transcription-card { 76 background: var(--background); 77 border: 2px solid var(--secondary); 78 border-radius: 8px; 79 padding: 1.5rem; 80 cursor: pointer; 81 transition: border-color 0.2s; 82 } 83 84 .transcription-card:hover { 85 border-color: var(--primary); 86 } 87 88 .card-header { 89 display: flex; 90 justify-content: space-between; 91 align-items: flex-start; 92 margin-bottom: 1rem; 93 } 94 95 .filename { 96 font-size: 1.125rem; 97 font-weight: 600; 98 color: var(--text); 99 margin-bottom: 0.5rem; 100 } 101 102 .status-badge { 103 padding: 0.5rem 1rem; 104 border-radius: 4px; 105 font-size: 0.875rem; 106 font-weight: 600; 107 text-transform: uppercase; 108 } 109 110 .status-completed { 111 background: color-mix(in srgb, green 10%, transparent); 112 color: green; 113 } 114 115 .status-failed { 116 background: color-mix(in srgb, red 10%, transparent); 117 color: red; 118 } 119 120 .status-processing, 121 .status-transcribing, 122 .status-uploading, 123 .status-selected { 124 background: color-mix(in srgb, var(--accent) 10%, transparent); 125 color: var(--accent); 126 } 127 128 .status-pending { 129 background: color-mix(in srgb, var(--paynes-gray) 10%, transparent); 130 color: var(--paynes-gray); 131 } 132 133 .meta-row { 134 display: flex; 135 gap: 2rem; 136 flex-wrap: wrap; 137 align-items: center; 138 } 139 140 .user-info { 141 display: flex; 142 align-items: center; 143 gap: 0.5rem; 144 } 145 146 .user-avatar { 147 width: 2rem; 148 height: 2rem; 149 border-radius: 50%; 150 } 151 152 .timestamp { 153 color: var(--paynes-gray); 154 font-size: 0.875rem; 155 } 156 157 .delete-btn { 158 background: transparent; 159 border: 2px solid #dc2626; 160 color: #dc2626; 161 padding: 0.5rem 1rem; 162 border-radius: 4px; 163 cursor: pointer; 164 font-size: 0.875rem; 165 font-weight: 600; 166 transition: all 0.2s; 167 margin-top: 1rem; 168 } 169 170 .delete-btn:hover:not(:disabled) { 171 background: #dc2626; 172 color: var(--white); 173 } 174 175 .delete-btn:disabled { 176 opacity: 0.5; 177 cursor: not-allowed; 178 } 179 `; 180 181 override async connectedCallback() { 182 super.connectedCallback(); 183 await this.loadTranscriptions(); 184 } 185 186 private async loadTranscriptions() { 187 this.isLoading = true; 188 this.error = null; 189 190 try { 191 const response = await fetch("/api/admin/transcriptions"); 192 if (!response.ok) { 193 const data = await response.json(); 194 throw new Error(data.error || "Failed to load transcriptions"); 195 } 196 197 this.transcriptions = await response.json(); 198 } catch (err) { 199 this.error = 200 err instanceof Error 201 ? err.message 202 : "Failed to load transcriptions. Please try again."; 203 } finally { 204 this.isLoading = false; 205 } 206 } 207 208 private async handleDelete(transcriptionId: string) { 209 if ( 210 !confirm( 211 "Are you sure you want to delete this transcription? This cannot be undone.", 212 ) 213 ) { 214 return; 215 } 216 217 this.error = null; 218 try { 219 const response = await fetch( 220 `/api/admin/transcriptions/${transcriptionId}`, 221 { 222 method: "DELETE", 223 }, 224 ); 225 226 if (!response.ok) { 227 const data = await response.json(); 228 throw new Error(data.error || "Failed to delete transcription"); 229 } 230 231 await this.loadTranscriptions(); 232 this.dispatchEvent(new CustomEvent("transcription-deleted")); 233 } catch (err) { 234 this.error = 235 err instanceof Error 236 ? err.message 237 : "Failed to delete transcription. Please try again."; 238 } 239 } 240 241 private handleCardClick(transcriptionId: string, event: Event) { 242 // Don't open modal if clicking on delete button 243 if ((event.target as HTMLElement).closest(".delete-btn")) { 244 return; 245 } 246 this.dispatchEvent( 247 new CustomEvent("open-transcription", { 248 detail: { id: transcriptionId }, 249 }), 250 ); 251 } 252 253 private formatTimestamp(timestamp: number): string { 254 const date = new Date(timestamp * 1000); 255 return date.toLocaleString(); 256 } 257 258 private get filteredTranscriptions() { 259 if (!this.searchQuery) return this.transcriptions; 260 261 const query = this.searchQuery.toLowerCase(); 262 return this.transcriptions.filter( 263 (t) => 264 t.original_filename.toLowerCase().includes(query) || 265 t.user_name?.toLowerCase().includes(query) || 266 t.user_email.toLowerCase().includes(query), 267 ); 268 } 269 270 override render() { 271 if (this.isLoading) { 272 return html`<div class="loading">Loading transcriptions...</div>`; 273 } 274 275 if (this.error) { 276 return html` 277 <div class="error-banner">${this.error}</div> 278 <button @click=${this.loadTranscriptions}>Retry</button> 279 `; 280 } 281 282 const filtered = this.filteredTranscriptions; 283 284 return html` 285 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""} 286 287 <input 288 type="text" 289 class="search-box" 290 placeholder="Search by filename or user..." 291 .value=${this.searchQuery} 292 @input=${(e: Event) => { 293 this.searchQuery = (e.target as HTMLInputElement).value; 294 }} 295 /> 296 297 ${ 298 filtered.length === 0 299 ? html`<div class="empty-state">No transcriptions found</div>` 300 : html` 301 <div class="transcriptions-grid"> 302 ${filtered.map( 303 (t) => html` 304 <div class="transcription-card" @click=${(e: Event) => this.handleCardClick(t.id, e)}> 305 <div class="card-header"> 306 <div class="filename">${t.original_filename}</div> 307 <span class="status-badge status-${t.status}">${t.status}</span> 308 </div> 309 310 <div class="meta-row"> 311 <div class="user-info"> 312 <img 313 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${t.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 314 alt="Avatar" 315 class="user-avatar" 316 /> 317 <span>${t.user_name || t.user_email}</span> 318 </div> 319 <span class="timestamp">${this.formatTimestamp(t.created_at)}</span> 320 </div> 321 322 ${ 323 t.error_message 324 ? html`<div class="error" style="margin-top: 1rem;">${t.error_message}</div>` 325 : "" 326 } 327 328 <button class="delete-btn" @click=${() => this.handleDelete(t.id)}> 329 Delete 330 </button> 331 </div> 332 `, 333 )} 334 </div> 335 ` 336 } 337 `; 338 } 339}