馃 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 .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}