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