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