馃 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 = err instanceof Error ? err.message : "Failed to load transcriptions. Please try again."; 200 } finally { 201 this.isLoading = false; 202 } 203 } 204 205 private async handleDelete(transcriptionId: string) { 206 if ( 207 !confirm( 208 "Are you sure you want to delete this transcription? This cannot be undone.", 209 ) 210 ) { 211 return; 212 } 213 214 this.error = null; 215 try { 216 const response = await fetch( 217 `/api/admin/transcriptions/${transcriptionId}`, 218 { 219 method: "DELETE", 220 }, 221 ); 222 223 if (!response.ok) { 224 const data = await response.json(); 225 throw new Error(data.error || "Failed to delete transcription"); 226 } 227 228 await this.loadTranscriptions(); 229 this.dispatchEvent(new CustomEvent("transcription-deleted")); 230 } catch (err) { 231 this.error = err instanceof Error ? err.message : "Failed to delete transcription. Please try again."; 232 } 233 } 234 235 private handleCardClick(transcriptionId: string, event: Event) { 236 // Don't open modal if clicking on delete button 237 if ((event.target as HTMLElement).closest(".delete-btn")) { 238 return; 239 } 240 this.dispatchEvent( 241 new CustomEvent("open-transcription", { 242 detail: { id: transcriptionId }, 243 }), 244 ); 245 } 246 247 private formatTimestamp(timestamp: number): string { 248 const date = new Date(timestamp * 1000); 249 return date.toLocaleString(); 250 } 251 252 private get filteredTranscriptions() { 253 if (!this.searchQuery) return this.transcriptions; 254 255 const query = this.searchQuery.toLowerCase(); 256 return this.transcriptions.filter( 257 (t) => 258 t.original_filename.toLowerCase().includes(query) || 259 t.user_name?.toLowerCase().includes(query) || 260 t.user_email.toLowerCase().includes(query), 261 ); 262 } 263 264 override render() { 265 if (this.isLoading) { 266 return html`<div class="loading">Loading transcriptions...</div>`; 267 } 268 269 if (this.error) { 270 return html` 271 <div class="error-banner">${this.error}</div> 272 <button @click=${this.loadTranscriptions}>Retry</button> 273 `; 274 } 275 276 const filtered = this.filteredTranscriptions; 277 278 return html` 279 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""} 280 281 <input 282 type="text" 283 class="search-box" 284 placeholder="Search by filename or user..." 285 .value=${this.searchQuery} 286 @input=${(e: Event) => { 287 this.searchQuery = (e.target as HTMLInputElement).value; 288 }} 289 /> 290 291 ${ 292 filtered.length === 0 293 ? html`<div class="empty-state">No transcriptions found</div>` 294 : html` 295 <div class="transcriptions-grid"> 296 ${filtered.map( 297 (t) => html` 298 <div class="transcription-card" @click=${(e: Event) => this.handleCardClick(t.id, e)}> 299 <div class="card-header"> 300 <div class="filename">${t.original_filename}</div> 301 <span class="status-badge status-${t.status}">${t.status}</span> 302 </div> 303 304 <div class="meta-row"> 305 <div class="user-info"> 306 <img 307 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${t.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 308 alt="Avatar" 309 class="user-avatar" 310 /> 311 <span>${t.user_name || t.user_email}</span> 312 </div> 313 <span class="timestamp">${this.formatTimestamp(t.created_at)}</span> 314 </div> 315 316 ${ 317 t.error_message 318 ? html`<div class="error" style="margin-top: 1rem;">${t.error_message}</div>` 319 : "" 320 } 321 322 <button class="delete-btn" @click=${() => this.handleDelete(t.id)}> 323 Delete 324 </button> 325 </div> 326 `, 327 )} 328 </div> 329 ` 330 } 331 `; 332 } 333}