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