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