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