🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement Content Security Policy

- Extract inline scripts to external .ts files (index, admin, reset-password)
- Extract inline styles to external .css files (index, admin, settings, transcribe, reset-password)
- Add CSP meta tags to all HTML pages with strict policy
- CSP policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:
- Replace inline style attributes with CSS classes (.hidden, .back-link, .mb-1)

This provides strong XSS protection while maintaining compatibility with
Bun's HTML bundler pattern. CSP is enforced via meta tags in each HTML page.

💘 Generated with Crush

Assisted-by: Claude Sonnet 4.5 via Crush <crush@charm.land>

+403 -386
+5 -254
src/pages/admin.html
··· 4 4 <head> 5 5 <meta charset="UTF-8"> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'"> 7 8 <title>Admin - Thistle</title> 8 9 <link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png"> 9 10 <link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png"> 10 11 <link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png"> 11 12 <link rel="manifest" href="../../public/favicon/site.webmanifest"> 12 13 <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> 14 + <link rel="stylesheet" href="../styles/admin.css"> 131 15 </head> 132 16 133 17 <body> ··· 144 28 <main> 145 29 <h1>Admin Dashboard</h1> 146 30 147 - <div id="error-message" class="error" style="display: none;"></div> 31 + <div id="error-message" class="error hidden"></div> 148 32 149 33 <div id="loading" class="loading">Loading...</div> 150 34 151 - <div id="content" style="display: none;"> 35 + <div id="content" class="hidden"> 152 36 <div class="stats"> 153 37 <div class="stat-card"> 154 38 <div class="stat-value" id="total-users">0</div> ··· 211 95 <script type="module" src="../components/admin-classes.ts"></script> 212 96 <script type="module" src="../components/user-modal.ts"></script> 213 97 <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 - // Remove subtab param when leaving classes tab 323 - if (tabName !== 'classes') { 324 - url.searchParams.delete('subtab'); 325 - } 326 - window.history.pushState({}, '', url); 327 - } 328 - } 329 - 330 - document.querySelectorAll('.tab').forEach(tab => { 331 - tab.addEventListener('click', () => { 332 - switchTab(tab.dataset.tab); 333 - }); 334 - }); 335 - 336 - // Check for tab query parameter on load 337 - const params = new URLSearchParams(window.location.search); 338 - const initialTab = params.get('tab'); 339 - const validTabs = ['pending', 'transcriptions', 'users', 'classes']; 340 - 341 - if (initialTab && validTabs.includes(initialTab)) { 342 - switchTab(initialTab); 343 - } 344 - 345 - // Initialize 346 - loadStats(); 347 - </script> 98 + <script type="module" src="./admin.ts"></script> 348 99 </body> 349 100 350 101 </html>
+136
src/pages/admin.ts
··· 1 + const transcriptionsComponent = document.getElementById('transcriptions-component') as any; 2 + const usersComponent = document.getElementById('users-component') as any; 3 + const userModal = document.getElementById('user-modal') as any; 4 + const transcriptModal = document.getElementById('transcript-modal') as any; 5 + const errorMessage = document.getElementById('error-message') as HTMLElement; 6 + const loading = document.getElementById('loading') as HTMLElement; 7 + const content = document.getElementById('content') as HTMLElement; 8 + 9 + // Modal functions 10 + function openUserModal(userId: string) { 11 + userModal.setAttribute('open', ''); 12 + userModal.userId = userId; 13 + } 14 + 15 + function closeUserModal() { 16 + userModal.removeAttribute('open'); 17 + userModal.userId = null; 18 + } 19 + 20 + function openTranscriptModal(transcriptId: string) { 21 + transcriptModal.setAttribute('open', ''); 22 + transcriptModal.transcriptId = transcriptId; 23 + } 24 + 25 + function closeTranscriptModal() { 26 + transcriptModal.removeAttribute('open'); 27 + transcriptModal.transcriptId = null; 28 + } 29 + 30 + // Listen for component events 31 + transcriptionsComponent?.addEventListener('open-transcription', (e: CustomEvent) => { 32 + openTranscriptModal(e.detail.id); 33 + }); 34 + 35 + usersComponent?.addEventListener('open-user', (e: CustomEvent) => { 36 + openUserModal(e.detail.id); 37 + }); 38 + 39 + // Listen for modal close events 40 + userModal?.addEventListener('close', closeUserModal); 41 + userModal?.addEventListener('user-updated', async () => { 42 + await loadStats(); 43 + }); 44 + userModal?.addEventListener('click', (e: MouseEvent) => { 45 + if (e.target === userModal) closeUserModal(); 46 + }); 47 + 48 + transcriptModal?.addEventListener('close', closeTranscriptModal); 49 + transcriptModal?.addEventListener('transcript-deleted', async () => { 50 + await loadStats(); 51 + }); 52 + transcriptModal?.addEventListener('click', (e: MouseEvent) => { 53 + if (e.target === transcriptModal) closeTranscriptModal(); 54 + }); 55 + 56 + async function loadStats() { 57 + try { 58 + const [transcriptionsRes, usersRes] = await Promise.all([ 59 + fetch('/api/admin/transcriptions'), 60 + fetch('/api/admin/users') 61 + ]); 62 + 63 + if (!transcriptionsRes.ok || !usersRes.ok) { 64 + if (transcriptionsRes.status === 403 || usersRes.status === 403) { 65 + window.location.href = '/'; 66 + return; 67 + } 68 + throw new Error('Failed to load admin data'); 69 + } 70 + 71 + const transcriptions = await transcriptionsRes.json(); 72 + const users = await usersRes.json(); 73 + 74 + const totalUsers = document.getElementById('total-users'); 75 + const totalTranscriptions = document.getElementById('total-transcriptions'); 76 + const failedTranscriptions = document.getElementById('failed-transcriptions'); 77 + 78 + if (totalUsers) totalUsers.textContent = users.length.toString(); 79 + if (totalTranscriptions) totalTranscriptions.textContent = transcriptions.length.toString(); 80 + 81 + const failed = transcriptions.filter((t: any) => t.status === 'failed'); 82 + if (failedTranscriptions) failedTranscriptions.textContent = failed.length.toString(); 83 + 84 + loading.classList.add('hidden'); 85 + content.classList.remove('hidden'); 86 + } catch (error) { 87 + errorMessage.textContent = (error as Error).message; 88 + errorMessage.classList.remove('hidden'); 89 + loading.classList.add('hidden'); 90 + } 91 + } 92 + 93 + // Tab switching 94 + function switchTab(tabName: string) { 95 + document.querySelectorAll('.tab').forEach(t => { 96 + t.classList.remove('active'); 97 + }); 98 + document.querySelectorAll('.tab-content').forEach(c => { 99 + c.classList.remove('active'); 100 + }); 101 + 102 + const tabButton = document.querySelector(`[data-tab="${tabName}"]`); 103 + const tabContent = document.getElementById(`${tabName}-tab`); 104 + 105 + if (tabButton && tabContent) { 106 + tabButton.classList.add('active'); 107 + tabContent.classList.add('active'); 108 + 109 + // Update URL without reloading 110 + const url = new URL(window.location.href); 111 + url.searchParams.set('tab', tabName); 112 + // Remove subtab param when leaving classes tab 113 + if (tabName !== 'classes') { 114 + url.searchParams.delete('subtab'); 115 + } 116 + window.history.pushState({}, '', url); 117 + } 118 + } 119 + 120 + document.querySelectorAll('.tab').forEach(tab => { 121 + tab.addEventListener('click', () => { 122 + switchTab((tab as HTMLElement).dataset.tab || ''); 123 + }); 124 + }); 125 + 126 + // Check for tab query parameter on load 127 + const params = new URLSearchParams(window.location.search); 128 + const initialTab = params.get('tab'); 129 + const validTabs = ['pending', 'transcriptions', 'users', 'classes']; 130 + 131 + if (initialTab && validTabs.includes(initialTab)) { 132 + switchTab(initialTab); 133 + } 134 + 135 + // Initialize 136 + loadStats();
+1
src/pages/checkout.html
··· 4 4 <head> 5 5 <meta charset="UTF-8"> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'"> 7 8 <title>Success! - Thistle</title> 8 9 <link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png"> 9 10 <link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
+1
src/pages/class.html
··· 4 4 <head> 5 5 <meta charset="UTF-8"> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'"> 7 8 <title>Class - Thistle</title> 8 9 <link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png"> 9 10 <link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
+1
src/pages/classes.html
··· 4 4 <head> 5 5 <meta charset="UTF-8"> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'"> 7 8 <title>Classes - Thistle</title> 8 9 <link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png"> 9 10 <link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
+3 -85
src/pages/index.html
··· 4 4 <head> 5 5 <meta charset="UTF-8"> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'"> 7 8 <title>Thistle</title> 8 9 <link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png"> 9 10 <link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png"> 10 11 <link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png"> 11 12 <link rel="manifest" href="../../public/favicon/site.webmanifest"> 12 13 <link rel="stylesheet" href="../styles/main.css"> 13 - <style> 14 - .hero-title { 15 - font-size: 3rem; 16 - font-weight: 700; 17 - color: var(--text); 18 - margin-bottom: 1rem; 19 - } 20 - 21 - .hero-subtitle { 22 - font-size: 1.25rem; 23 - color: var(--text); 24 - opacity: 0.8; 25 - margin-bottom: 2rem; 26 - } 27 - 28 - main { 29 - text-align: center; 30 - padding: 4rem 2rem; 31 - } 32 - 33 - .cta-buttons { 34 - display: flex; 35 - gap: 1rem; 36 - justify-content: center; 37 - margin-top: 2rem; 38 - } 39 - 40 - .btn { 41 - padding: 0.75rem 1.5rem; 42 - border-radius: 6px; 43 - font-size: 1rem; 44 - font-weight: 500; 45 - cursor: pointer; 46 - transition: all 0.2s; 47 - font-family: inherit; 48 - border: 2px solid; 49 - text-decoration: none; 50 - display: inline-block; 51 - } 52 - 53 - .btn-primary { 54 - background: var(--primary); 55 - color: white; 56 - border-color: var(--primary); 57 - } 58 - 59 - .btn-primary:hover { 60 - background: transparent; 61 - color: var(--primary); 62 - } 63 - 64 - .btn-secondary { 65 - background: transparent; 66 - color: var(--text); 67 - border-color: var(--secondary); 68 - } 69 - 70 - .btn-secondary:hover { 71 - border-color: var(--primary); 72 - color: var(--primary); 73 - } 74 - 75 - @media (max-width: 640px) { 76 - .hero-title { 77 - font-size: 2.5rem; 78 - } 79 - 80 - .cta-buttons { 81 - flex-direction: column; 82 - align-items: center; 83 - } 84 - } 85 - </style> 14 + <link rel="stylesheet" href="../styles/index.css"> 86 15 </head> 87 16 88 17 <body> ··· 106 35 </main> 107 36 108 37 <script type="module" src="../components/auth.ts"></script> 109 - <script type="module"> 110 - document.getElementById('start-btn').addEventListener('click', async () => { 111 - const authComponent = document.querySelector('auth-component'); 112 - const isLoggedIn = await authComponent.isAuthenticated(); 113 - 114 - if (isLoggedIn) { 115 - window.location.href = '/classes'; 116 - } else { 117 - authComponent.openAuthModal(); 118 - } 119 - }); 120 - </script> 38 + <script type="module" src="./index.ts"></script> 121 39 </body> 122 40 123 41 </html>
+10
src/pages/index.ts
··· 1 + document.getElementById('start-btn')?.addEventListener('click', async () => { 2 + const authComponent = document.querySelector('auth-component') as any; 3 + const isLoggedIn = await authComponent.isAuthenticated(); 4 + 5 + if (isLoggedIn) { 6 + window.location.href = '/classes'; 7 + } else { 8 + authComponent.openAuthModal(); 9 + } 10 + });
+3 -20
src/pages/reset-password.html
··· 4 4 <head> 5 5 <meta charset="UTF-8"> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'"> 7 8 <title>Reset Password - Thistle</title> 8 9 <link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png"> 9 10 <link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png"> 10 11 <link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png"> 11 12 <link rel="manifest" href="../../public/favicon/site.webmanifest"> 12 13 <link rel="stylesheet" href="../styles/main.css"> 13 - <style> 14 - main { 15 - display: flex; 16 - align-items: center; 17 - justify-content: center; 18 - padding: 4rem 1rem; 19 - } 20 - </style> 14 + <link rel="stylesheet" href="../styles/reset-password.css"> 21 15 </head> 22 16 23 17 <body> ··· 37 31 38 32 <script type="module" src="../components/auth.ts"></script> 39 33 <script type="module" src="../components/reset-password-form.ts"></script> 40 - <script type="module"> 41 - // Wait for component to be defined before setting token 42 - await customElements.whenDefined('reset-password-form'); 43 - 44 - // Get token from URL and pass to component 45 - const urlParams = new URLSearchParams(window.location.search); 46 - const token = urlParams.get('token'); 47 - const resetForm = document.getElementById('reset-form'); 48 - if (resetForm) { 49 - resetForm.token = token; 50 - } 51 - </script> 34 + <script type="module" src="./reset-password.ts"></script> 52 35 </body> 53 36 54 37 </html>
+10
src/pages/reset-password.ts
··· 1 + // Wait for component to be defined before setting token 2 + await customElements.whenDefined('reset-password-form'); 3 + 4 + // Get token from URL and pass to component 5 + const urlParams = new URLSearchParams(window.location.search); 6 + const token = urlParams.get('token'); 7 + const resetForm = document.getElementById('reset-form') as any; 8 + if (resetForm) { 9 + resetForm.token = token; 10 + }
+2 -6
src/pages/settings.html
··· 4 4 <head> 5 5 <meta charset="UTF-8"> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'"> 7 8 <title>Settings - Thistle</title> 8 9 <link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png"> 9 10 <link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png"> 10 11 <link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png"> 11 12 <link rel="manifest" href="../../public/favicon/site.webmanifest"> 12 13 <link rel="stylesheet" href="../styles/main.css"> 13 - 14 - <style> 15 - main { 16 - max-width: 64rem; 17 - } 18 - </style> 14 + <link rel="stylesheet" href="../styles/settings.css"> 19 15 </head> 20 16 21 17 <body>
+4 -21
src/pages/transcribe.html
··· 4 4 <head> 5 5 <meta charset="UTF-8"> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'"> 7 8 <title>Transcribe - Thistle</title> 8 9 <link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png"> 9 10 <link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png"> 10 11 <link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png"> 11 12 <link rel="manifest" href="../../public/favicon/site.webmanifest"> 12 13 <link rel="stylesheet" href="../styles/main.css"> 13 - <style> 14 - .page-header { 15 - text-align: center; 16 - margin-bottom: 3rem; 17 - } 18 - 19 - .page-title { 20 - font-size: 2.5rem; 21 - font-weight: 700; 22 - color: var(--text); 23 - margin-bottom: 0.5rem; 24 - } 25 - 26 - .page-subtitle { 27 - font-size: 1.125rem; 28 - color: var(--text); 29 - opacity: 0.8; 30 - } 31 - </style> 14 + <link rel="stylesheet" href="../styles/transcribe.css"> 32 15 </head> 33 16 34 17 <body> ··· 43 26 </header> 44 27 45 28 <main> 46 - <div style="margin-bottom: 1rem;"> 47 - <a href="/classes" style="color: var(--paynes-gray); text-decoration: none; font-size: 0.875rem;"> 29 + <div class="mb-1"> 30 + <a href="/classes" class="back-link"> 48 31 ← Back to classes 49 32 </a> 50 33 </div>
+120
src/styles/admin.css
··· 1 + main { 2 + max-width: 80rem; 3 + margin: 0 auto; 4 + padding: 2rem; 5 + } 6 + 7 + h1 { 8 + margin-bottom: 2rem; 9 + color: var(--text); 10 + } 11 + 12 + .section { 13 + margin-bottom: 3rem; 14 + } 15 + 16 + .section-title { 17 + font-size: 1.5rem; 18 + font-weight: 600; 19 + color: var(--text); 20 + margin-bottom: 1rem; 21 + display: flex; 22 + align-items: center; 23 + gap: 0.5rem; 24 + } 25 + 26 + .tabs { 27 + display: flex; 28 + gap: 1rem; 29 + border-bottom: 2px solid var(--secondary); 30 + margin-bottom: 2rem; 31 + } 32 + 33 + .tab { 34 + padding: 0.75rem 1.5rem; 35 + border: none; 36 + background: transparent; 37 + color: var(--text); 38 + cursor: pointer; 39 + font-size: 1rem; 40 + font-weight: 500; 41 + font-family: inherit; 42 + border-bottom: 2px solid transparent; 43 + margin-bottom: -2px; 44 + transition: all 0.2s; 45 + } 46 + 47 + .tab:hover { 48 + color: var(--primary); 49 + } 50 + 51 + .tab.active { 52 + color: var(--primary); 53 + border-bottom-color: var(--primary); 54 + } 55 + 56 + .tab-content { 57 + display: none; 58 + } 59 + 60 + .tab-content.active { 61 + display: block; 62 + } 63 + 64 + .empty-state { 65 + text-align: center; 66 + padding: 3rem; 67 + color: var(--text); 68 + opacity: 0.6; 69 + } 70 + 71 + .loading { 72 + text-align: center; 73 + padding: 3rem; 74 + color: var(--text); 75 + } 76 + 77 + .error { 78 + background: #fee2e2; 79 + color: #991b1b; 80 + padding: 1rem; 81 + border-radius: 6px; 82 + margin-bottom: 1rem; 83 + } 84 + 85 + .stats { 86 + display: grid; 87 + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); 88 + gap: 1rem; 89 + margin-bottom: 2rem; 90 + } 91 + 92 + .stat-card { 93 + background: var(--background); 94 + border: 2px solid var(--secondary); 95 + border-radius: 8px; 96 + padding: 1.5rem; 97 + } 98 + 99 + .stat-value { 100 + font-size: 2rem; 101 + font-weight: 700; 102 + color: var(--primary); 103 + margin-bottom: 0.25rem; 104 + } 105 + 106 + .stat-label { 107 + color: var(--text); 108 + opacity: 0.7; 109 + font-size: 0.875rem; 110 + } 111 + 112 + .timestamp { 113 + color: var(--text); 114 + opacity: 0.6; 115 + font-size: 0.875rem; 116 + } 117 + 118 + .hidden { 119 + display: none; 120 + }
+71
src/styles/index.css
··· 1 + .hero-title { 2 + font-size: 3rem; 3 + font-weight: 700; 4 + color: var(--text); 5 + margin-bottom: 1rem; 6 + } 7 + 8 + .hero-subtitle { 9 + font-size: 1.25rem; 10 + color: var(--text); 11 + opacity: 0.8; 12 + margin-bottom: 2rem; 13 + } 14 + 15 + main { 16 + text-align: center; 17 + padding: 4rem 2rem; 18 + } 19 + 20 + .cta-buttons { 21 + display: flex; 22 + gap: 1rem; 23 + justify-content: center; 24 + margin-top: 2rem; 25 + } 26 + 27 + .btn { 28 + padding: 0.75rem 1.5rem; 29 + border-radius: 6px; 30 + font-size: 1rem; 31 + font-weight: 500; 32 + cursor: pointer; 33 + transition: all 0.2s; 34 + font-family: inherit; 35 + border: 2px solid; 36 + text-decoration: none; 37 + display: inline-block; 38 + } 39 + 40 + .btn-primary { 41 + background: var(--primary); 42 + color: white; 43 + border-color: var(--primary); 44 + } 45 + 46 + .btn-primary:hover { 47 + background: transparent; 48 + color: var(--primary); 49 + } 50 + 51 + .btn-secondary { 52 + background: transparent; 53 + color: var(--text); 54 + border-color: var(--secondary); 55 + } 56 + 57 + .btn-secondary:hover { 58 + border-color: var(--primary); 59 + color: var(--primary); 60 + } 61 + 62 + @media (max-width: 640px) { 63 + .hero-title { 64 + font-size: 2.5rem; 65 + } 66 + 67 + .cta-buttons { 68 + flex-direction: column; 69 + align-items: center; 70 + } 71 + }
+6
src/styles/reset-password.css
··· 1 + main { 2 + display: flex; 3 + align-items: center; 4 + justify-content: center; 5 + padding: 4rem 1rem; 6 + }
+3
src/styles/settings.css
··· 1 + main { 2 + max-width: 64rem; 3 + }
+27
src/styles/transcribe.css
··· 1 + .page-header { 2 + text-align: center; 3 + margin-bottom: 3rem; 4 + } 5 + 6 + .page-title { 7 + font-size: 2.5rem; 8 + font-weight: 700; 9 + color: var(--text); 10 + margin-bottom: 0.5rem; 11 + } 12 + 13 + .page-subtitle { 14 + font-size: 1.125rem; 15 + color: var(--text); 16 + opacity: 0.8; 17 + } 18 + 19 + .back-link { 20 + color: var(--paynes-gray); 21 + text-decoration: none; 22 + font-size: 0.875rem; 23 + } 24 + 25 + .mb-1 { 26 + margin-bottom: 1rem; 27 + }