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