馃 distributed transcription service
thistle.dunkirk.sh
1<!DOCTYPE html>
2<html lang="en">
3
4<head>
5 <meta charset="UTF-8">
6 <meta name="viewport" content="width=device-width, initial-scale=1.0">
7 <title>Admin - Thistle</title>
8 <link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
9 <link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
10 <link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
11 <link rel="manifest" href="../../public/favicon/site.webmanifest">
12 <link rel="stylesheet" href="../styles/main.css">
13 <style>
14 main {
15 max-width: 80rem;
16 margin: 0 auto;
17 padding: 2rem;
18 }
19
20 h1 {
21 margin-bottom: 2rem;
22 color: var(--text);
23 }
24
25 .section {
26 margin-bottom: 3rem;
27 }
28
29 .section-title {
30 font-size: 1.5rem;
31 font-weight: 600;
32 color: var(--text);
33 margin-bottom: 1rem;
34 display: flex;
35 align-items: center;
36 gap: 0.5rem;
37 }
38
39 .tabs {
40 display: flex;
41 gap: 1rem;
42 border-bottom: 2px solid var(--secondary);
43 margin-bottom: 2rem;
44 }
45
46 .tab {
47 padding: 0.75rem 1.5rem;
48 border: none;
49 background: transparent;
50 color: var(--text);
51 cursor: pointer;
52 font-size: 1rem;
53 font-weight: 500;
54 font-family: inherit;
55 border-bottom: 2px solid transparent;
56 margin-bottom: -2px;
57 transition: all 0.2s;
58 }
59
60 .tab:hover {
61 color: var(--primary);
62 }
63
64 .tab.active {
65 color: var(--primary);
66 border-bottom-color: var(--primary);
67 }
68
69 .tab-content {
70 display: none;
71 }
72
73 .tab-content.active {
74 display: block;
75 }
76
77 .empty-state {
78 text-align: center;
79 padding: 3rem;
80 color: var(--text);
81 opacity: 0.6;
82 }
83
84 .loading {
85 text-align: center;
86 padding: 3rem;
87 color: var(--text);
88 }
89
90 .error {
91 background: #fee2e2;
92 color: #991b1b;
93 padding: 1rem;
94 border-radius: 6px;
95 margin-bottom: 1rem;
96 }
97
98 .stats {
99 display: grid;
100 grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
101 gap: 1rem;
102 margin-bottom: 2rem;
103 }
104
105 .stat-card {
106 background: var(--background);
107 border: 2px solid var(--secondary);
108 border-radius: 8px;
109 padding: 1.5rem;
110 }
111
112 .stat-value {
113 font-size: 2rem;
114 font-weight: 700;
115 color: var(--primary);
116 margin-bottom: 0.25rem;
117 }
118
119 .stat-label {
120 color: var(--text);
121 opacity: 0.7;
122 font-size: 0.875rem;
123 }
124
125 .timestamp {
126 color: var(--text);
127 opacity: 0.6;
128 font-size: 0.875rem;
129 }
130 </style>
131</head>
132
133<body>
134 <header>
135 <div class="header-content">
136 <a href="/" class="site-title">
137 <img src="../../public/favicon/favicon-32x32.png" alt="Thistle logo">
138 <span>Thistle</span>
139 </a>
140 <auth-component></auth-component>
141 </div>
142 </header>
143
144 <main>
145 <h1>Admin Dashboard</h1>
146
147 <div id="error-message" class="error" style="display: none;"></div>
148
149 <div id="loading" class="loading">Loading...</div>
150
151 <div id="content" style="display: none;">
152 <div class="stats">
153 <div class="stat-card">
154 <div class="stat-value" id="total-users">0</div>
155 <div class="stat-label">Total Users</div>
156 </div>
157 <div class="stat-card">
158 <div class="stat-value" id="total-transcriptions">0</div>
159 <div class="stat-label">Total Transcriptions</div>
160 </div>
161 <div class="stat-card">
162 <div class="stat-value" id="failed-transcriptions">0</div>
163 <div class="stat-label">Failed Transcriptions</div>
164 </div>
165 </div>
166
167 <div class="tabs">
168 <button class="tab active" data-tab="pending">Pending Recordings</button>
169 <button class="tab" data-tab="transcriptions">Transcriptions</button>
170 <button class="tab" data-tab="users">Users</button>
171 <button class="tab" data-tab="classes">Classes</button>
172 </div>
173
174 <div id="pending-tab" class="tab-content active">
175 <div class="section">
176 <h2 class="section-title">Pending Recordings</h2>
177 <admin-pending-recordings></admin-pending-recordings>
178 </div>
179 </div>
180
181 <div id="transcriptions-tab" class="tab-content">
182 <div class="section">
183 <h2 class="section-title">All Transcriptions</h2>
184 <admin-transcriptions id="transcriptions-component"></admin-transcriptions>
185 </div>
186 </div>
187
188 <div id="users-tab" class="tab-content">
189 <div class="section">
190 <h2 class="section-title">All Users</h2>
191 <admin-users id="users-component"></admin-users>
192 </div>
193 </div>
194
195 <div id="classes-tab" class="tab-content">
196 <div class="section">
197 <h2 class="section-title">Manage Classes</h2>
198 <admin-classes></admin-classes>
199 </div>
200 </div>
201 </div>
202 </main>
203
204 <user-modal id="user-modal"></user-modal>
205 <transcript-modal id="transcript-modal"></transcript-modal>
206
207 <script type="module" src="../components/auth.ts"></script>
208 <script type="module" src="../components/admin-pending-recordings.ts"></script>
209 <script type="module" src="../components/admin-transcriptions.ts"></script>
210 <script type="module" src="../components/admin-users.ts"></script>
211 <script type="module" src="../components/admin-classes.ts"></script>
212 <script type="module" src="../components/user-modal.ts"></script>
213 <script type="module" src="../components/transcript-view-modal.ts"></script>
214 <script type="module">
215 const transcriptionsComponent = document.getElementById('transcriptions-component');
216 const usersComponent = document.getElementById('users-component');
217 const userModal = document.getElementById('user-modal');
218 const transcriptModal = document.getElementById('transcript-modal');
219 const errorMessage = document.getElementById('error-message');
220 const loading = document.getElementById('loading');
221 const content = document.getElementById('content');
222
223 // Modal functions
224 function openUserModal(userId) {
225 userModal.setAttribute('open', '');
226 userModal.userId = userId;
227 }
228
229 function closeUserModal() {
230 userModal.removeAttribute('open');
231 userModal.userId = null;
232 }
233
234 function openTranscriptModal(transcriptId) {
235 transcriptModal.setAttribute('open', '');
236 transcriptModal.transcriptId = transcriptId;
237 }
238
239 function closeTranscriptModal() {
240 transcriptModal.removeAttribute('open');
241 transcriptModal.transcriptId = null;
242 }
243
244 // Listen for component events
245 transcriptionsComponent.addEventListener('open-transcription', (e) => {
246 openTranscriptModal(e.detail.id);
247 });
248
249 usersComponent.addEventListener('open-user', (e) => {
250 openUserModal(e.detail.id);
251 });
252
253 // Listen for modal close events
254 userModal.addEventListener('close', closeUserModal);
255 userModal.addEventListener('user-updated', async () => {
256 await loadStats();
257 });
258 userModal.addEventListener('click', (e) => {
259 if (e.target === userModal) closeUserModal();
260 });
261
262 transcriptModal.addEventListener('close', closeTranscriptModal);
263 transcriptModal.addEventListener('transcript-deleted', async () => {
264 await loadStats();
265 });
266 transcriptModal.addEventListener('click', (e) => {
267 if (e.target === transcriptModal) closeTranscriptModal();
268 });
269
270 async function loadStats() {
271 try {
272 const [transcriptionsRes, usersRes] = await Promise.all([
273 fetch('/api/admin/transcriptions'),
274 fetch('/api/admin/users')
275 ]);
276
277 if (!transcriptionsRes.ok || !usersRes.ok) {
278 if (transcriptionsRes.status === 403 || usersRes.status === 403) {
279 window.location.href = '/';
280 return;
281 }
282 throw new Error('Failed to load admin data');
283 }
284
285 const transcriptions = await transcriptionsRes.json();
286 const users = await usersRes.json();
287
288 document.getElementById('total-users').textContent = users.length;
289 document.getElementById('total-transcriptions').textContent = transcriptions.length;
290
291 const failed = transcriptions.filter(t => t.status === 'failed');
292 document.getElementById('failed-transcriptions').textContent = failed.length;
293
294 loading.style.display = 'none';
295 content.style.display = 'block';
296 } catch (error) {
297 errorMessage.textContent = error.message;
298 errorMessage.style.display = 'block';
299 loading.style.display = 'none';
300 }
301 }
302
303 // Tab switching
304 function switchTab(tabName) {
305 document.querySelectorAll('.tab').forEach(t => {
306 t.classList.remove('active');
307 });
308 document.querySelectorAll('.tab-content').forEach(c => {
309 c.classList.remove('active');
310 });
311
312 const tabButton = document.querySelector(`[data-tab="${tabName}"]`);
313 const tabContent = document.getElementById(`${tabName}-tab`);
314
315 if (tabButton && tabContent) {
316 tabButton.classList.add('active');
317 tabContent.classList.add('active');
318
319 // Update URL without reloading
320 const url = new URL(window.location.href);
321 url.searchParams.set('tab', tabName);
322 window.history.pushState({}, '', url);
323 }
324 }
325
326 document.querySelectorAll('.tab').forEach(tab => {
327 tab.addEventListener('click', () => {
328 switchTab(tab.dataset.tab);
329 });
330 });
331
332 // Check for tab query parameter on load
333 const params = new URLSearchParams(window.location.search);
334 const initialTab = params.get('tab');
335 const validTabs = ['pending', 'transcriptions', 'users', 'classes'];
336
337 if (initialTab && validTabs.includes(initialTab)) {
338 switchTab(initialTab);
339 }
340
341 // Initialize
342 loadStats();
343 </script>
344</body>
345
346</html>