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