🪻 distributed transcription service thistle.dunkirk.sh

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>

dunkirk.sh 3756b706 23381dd7

verified
+5 -254
src/pages/admin.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<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'">
<title>Admin - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
-
<style>
-
main {
-
max-width: 80rem;
-
margin: 0 auto;
-
padding: 2rem;
-
}
-
-
h1 {
-
margin-bottom: 2rem;
-
color: var(--text);
-
}
-
-
.section {
-
margin-bottom: 3rem;
-
}
-
-
.section-title {
-
font-size: 1.5rem;
-
font-weight: 600;
-
color: var(--text);
-
margin-bottom: 1rem;
-
display: flex;
-
align-items: center;
-
gap: 0.5rem;
-
}
-
-
.tabs {
-
display: flex;
-
gap: 1rem;
-
border-bottom: 2px solid var(--secondary);
-
margin-bottom: 2rem;
-
}
-
-
.tab {
-
padding: 0.75rem 1.5rem;
-
border: none;
-
background: transparent;
-
color: var(--text);
-
cursor: pointer;
-
font-size: 1rem;
-
font-weight: 500;
-
font-family: inherit;
-
border-bottom: 2px solid transparent;
-
margin-bottom: -2px;
-
transition: all 0.2s;
-
}
-
-
.tab:hover {
-
color: var(--primary);
-
}
-
-
.tab.active {
-
color: var(--primary);
-
border-bottom-color: var(--primary);
-
}
-
-
.tab-content {
-
display: none;
-
}
-
-
.tab-content.active {
-
display: block;
-
}
-
-
.empty-state {
-
text-align: center;
-
padding: 3rem;
-
color: var(--text);
-
opacity: 0.6;
-
}
-
-
.loading {
-
text-align: center;
-
padding: 3rem;
-
color: var(--text);
-
}
-
-
.error {
-
background: #fee2e2;
-
color: #991b1b;
-
padding: 1rem;
-
border-radius: 6px;
-
margin-bottom: 1rem;
-
}
-
-
.stats {
-
display: grid;
-
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
-
gap: 1rem;
-
margin-bottom: 2rem;
-
}
-
-
.stat-card {
-
background: var(--background);
-
border: 2px solid var(--secondary);
-
border-radius: 8px;
-
padding: 1.5rem;
-
}
-
-
.stat-value {
-
font-size: 2rem;
-
font-weight: 700;
-
color: var(--primary);
-
margin-bottom: 0.25rem;
-
}
-
-
.stat-label {
-
color: var(--text);
-
opacity: 0.7;
-
font-size: 0.875rem;
-
}
-
-
.timestamp {
-
color: var(--text);
-
opacity: 0.6;
-
font-size: 0.875rem;
-
}
-
</style>
+
<link rel="stylesheet" href="../styles/admin.css">
</head>
<body>
···
<main>
<h1>Admin Dashboard</h1>
-
<div id="error-message" class="error" style="display: none;"></div>
+
<div id="error-message" class="error hidden"></div>
<div id="loading" class="loading">Loading...</div>
-
<div id="content" style="display: none;">
+
<div id="content" class="hidden">
<div class="stats">
<div class="stat-card">
<div class="stat-value" id="total-users">0</div>
···
<script type="module" src="../components/admin-classes.ts"></script>
<script type="module" src="../components/user-modal.ts"></script>
<script type="module" src="../components/transcript-view-modal.ts"></script>
-
<script type="module">
-
const transcriptionsComponent = document.getElementById('transcriptions-component');
-
const usersComponent = document.getElementById('users-component');
-
const userModal = document.getElementById('user-modal');
-
const transcriptModal = document.getElementById('transcript-modal');
-
const errorMessage = document.getElementById('error-message');
-
const loading = document.getElementById('loading');
-
const content = document.getElementById('content');
-
-
// Modal functions
-
function openUserModal(userId) {
-
userModal.setAttribute('open', '');
-
userModal.userId = userId;
-
}
-
-
function closeUserModal() {
-
userModal.removeAttribute('open');
-
userModal.userId = null;
-
}
-
-
function openTranscriptModal(transcriptId) {
-
transcriptModal.setAttribute('open', '');
-
transcriptModal.transcriptId = transcriptId;
-
}
-
-
function closeTranscriptModal() {
-
transcriptModal.removeAttribute('open');
-
transcriptModal.transcriptId = null;
-
}
-
-
// Listen for component events
-
transcriptionsComponent.addEventListener('open-transcription', (e) => {
-
openTranscriptModal(e.detail.id);
-
});
-
-
usersComponent.addEventListener('open-user', (e) => {
-
openUserModal(e.detail.id);
-
});
-
-
// Listen for modal close events
-
userModal.addEventListener('close', closeUserModal);
-
userModal.addEventListener('user-updated', async () => {
-
await loadStats();
-
});
-
userModal.addEventListener('click', (e) => {
-
if (e.target === userModal) closeUserModal();
-
});
-
-
transcriptModal.addEventListener('close', closeTranscriptModal);
-
transcriptModal.addEventListener('transcript-deleted', async () => {
-
await loadStats();
-
});
-
transcriptModal.addEventListener('click', (e) => {
-
if (e.target === transcriptModal) closeTranscriptModal();
-
});
-
-
async function loadStats() {
-
try {
-
const [transcriptionsRes, usersRes] = await Promise.all([
-
fetch('/api/admin/transcriptions'),
-
fetch('/api/admin/users')
-
]);
-
-
if (!transcriptionsRes.ok || !usersRes.ok) {
-
if (transcriptionsRes.status === 403 || usersRes.status === 403) {
-
window.location.href = '/';
-
return;
-
}
-
throw new Error('Failed to load admin data');
-
}
-
-
const transcriptions = await transcriptionsRes.json();
-
const users = await usersRes.json();
-
-
document.getElementById('total-users').textContent = users.length;
-
document.getElementById('total-transcriptions').textContent = transcriptions.length;
-
-
const failed = transcriptions.filter(t => t.status === 'failed');
-
document.getElementById('failed-transcriptions').textContent = failed.length;
-
-
loading.style.display = 'none';
-
content.style.display = 'block';
-
} catch (error) {
-
errorMessage.textContent = error.message;
-
errorMessage.style.display = 'block';
-
loading.style.display = 'none';
-
}
-
}
-
-
// Tab switching
-
function switchTab(tabName) {
-
document.querySelectorAll('.tab').forEach(t => {
-
t.classList.remove('active');
-
});
-
document.querySelectorAll('.tab-content').forEach(c => {
-
c.classList.remove('active');
-
});
-
-
const tabButton = document.querySelector(`[data-tab="${tabName}"]`);
-
const tabContent = document.getElementById(`${tabName}-tab`);
-
-
if (tabButton && tabContent) {
-
tabButton.classList.add('active');
-
tabContent.classList.add('active');
-
-
// Update URL without reloading
-
const url = new URL(window.location.href);
-
url.searchParams.set('tab', tabName);
-
// Remove subtab param when leaving classes tab
-
if (tabName !== 'classes') {
-
url.searchParams.delete('subtab');
-
}
-
window.history.pushState({}, '', url);
-
}
-
}
-
-
document.querySelectorAll('.tab').forEach(tab => {
-
tab.addEventListener('click', () => {
-
switchTab(tab.dataset.tab);
-
});
-
});
-
-
// Check for tab query parameter on load
-
const params = new URLSearchParams(window.location.search);
-
const initialTab = params.get('tab');
-
const validTabs = ['pending', 'transcriptions', 'users', 'classes'];
-
-
if (initialTab && validTabs.includes(initialTab)) {
-
switchTab(initialTab);
-
}
-
-
// Initialize
-
loadStats();
-
</script>
+
<script type="module" src="./admin.ts"></script>
</body>
</html>
+136
src/pages/admin.ts
···
+
const transcriptionsComponent = document.getElementById('transcriptions-component') as any;
+
const usersComponent = document.getElementById('users-component') as any;
+
const userModal = document.getElementById('user-modal') as any;
+
const transcriptModal = document.getElementById('transcript-modal') as any;
+
const errorMessage = document.getElementById('error-message') as HTMLElement;
+
const loading = document.getElementById('loading') as HTMLElement;
+
const content = document.getElementById('content') as HTMLElement;
+
+
// Modal functions
+
function openUserModal(userId: string) {
+
userModal.setAttribute('open', '');
+
userModal.userId = userId;
+
}
+
+
function closeUserModal() {
+
userModal.removeAttribute('open');
+
userModal.userId = null;
+
}
+
+
function openTranscriptModal(transcriptId: string) {
+
transcriptModal.setAttribute('open', '');
+
transcriptModal.transcriptId = transcriptId;
+
}
+
+
function closeTranscriptModal() {
+
transcriptModal.removeAttribute('open');
+
transcriptModal.transcriptId = null;
+
}
+
+
// Listen for component events
+
transcriptionsComponent?.addEventListener('open-transcription', (e: CustomEvent) => {
+
openTranscriptModal(e.detail.id);
+
});
+
+
usersComponent?.addEventListener('open-user', (e: CustomEvent) => {
+
openUserModal(e.detail.id);
+
});
+
+
// Listen for modal close events
+
userModal?.addEventListener('close', closeUserModal);
+
userModal?.addEventListener('user-updated', async () => {
+
await loadStats();
+
});
+
userModal?.addEventListener('click', (e: MouseEvent) => {
+
if (e.target === userModal) closeUserModal();
+
});
+
+
transcriptModal?.addEventListener('close', closeTranscriptModal);
+
transcriptModal?.addEventListener('transcript-deleted', async () => {
+
await loadStats();
+
});
+
transcriptModal?.addEventListener('click', (e: MouseEvent) => {
+
if (e.target === transcriptModal) closeTranscriptModal();
+
});
+
+
async function loadStats() {
+
try {
+
const [transcriptionsRes, usersRes] = await Promise.all([
+
fetch('/api/admin/transcriptions'),
+
fetch('/api/admin/users')
+
]);
+
+
if (!transcriptionsRes.ok || !usersRes.ok) {
+
if (transcriptionsRes.status === 403 || usersRes.status === 403) {
+
window.location.href = '/';
+
return;
+
}
+
throw new Error('Failed to load admin data');
+
}
+
+
const transcriptions = await transcriptionsRes.json();
+
const users = await usersRes.json();
+
+
const totalUsers = document.getElementById('total-users');
+
const totalTranscriptions = document.getElementById('total-transcriptions');
+
const failedTranscriptions = document.getElementById('failed-transcriptions');
+
+
if (totalUsers) totalUsers.textContent = users.length.toString();
+
if (totalTranscriptions) totalTranscriptions.textContent = transcriptions.length.toString();
+
+
const failed = transcriptions.filter((t: any) => t.status === 'failed');
+
if (failedTranscriptions) failedTranscriptions.textContent = failed.length.toString();
+
+
loading.classList.add('hidden');
+
content.classList.remove('hidden');
+
} catch (error) {
+
errorMessage.textContent = (error as Error).message;
+
errorMessage.classList.remove('hidden');
+
loading.classList.add('hidden');
+
}
+
}
+
+
// Tab switching
+
function switchTab(tabName: string) {
+
document.querySelectorAll('.tab').forEach(t => {
+
t.classList.remove('active');
+
});
+
document.querySelectorAll('.tab-content').forEach(c => {
+
c.classList.remove('active');
+
});
+
+
const tabButton = document.querySelector(`[data-tab="${tabName}"]`);
+
const tabContent = document.getElementById(`${tabName}-tab`);
+
+
if (tabButton && tabContent) {
+
tabButton.classList.add('active');
+
tabContent.classList.add('active');
+
+
// Update URL without reloading
+
const url = new URL(window.location.href);
+
url.searchParams.set('tab', tabName);
+
// Remove subtab param when leaving classes tab
+
if (tabName !== 'classes') {
+
url.searchParams.delete('subtab');
+
}
+
window.history.pushState({}, '', url);
+
}
+
}
+
+
document.querySelectorAll('.tab').forEach(tab => {
+
tab.addEventListener('click', () => {
+
switchTab((tab as HTMLElement).dataset.tab || '');
+
});
+
});
+
+
// Check for tab query parameter on load
+
const params = new URLSearchParams(window.location.search);
+
const initialTab = params.get('tab');
+
const validTabs = ['pending', 'transcriptions', 'users', 'classes'];
+
+
if (initialTab && validTabs.includes(initialTab)) {
+
switchTab(initialTab);
+
}
+
+
// Initialize
+
loadStats();
+1
src/pages/checkout.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<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'">
<title>Success! - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
+1
src/pages/class.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<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'">
<title>Class - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
+1
src/pages/classes.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<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'">
<title>Classes - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
+3 -85
src/pages/index.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<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'">
<title>Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
-
<style>
-
.hero-title {
-
font-size: 3rem;
-
font-weight: 700;
-
color: var(--text);
-
margin-bottom: 1rem;
-
}
-
-
.hero-subtitle {
-
font-size: 1.25rem;
-
color: var(--text);
-
opacity: 0.8;
-
margin-bottom: 2rem;
-
}
-
-
main {
-
text-align: center;
-
padding: 4rem 2rem;
-
}
-
-
.cta-buttons {
-
display: flex;
-
gap: 1rem;
-
justify-content: center;
-
margin-top: 2rem;
-
}
-
-
.btn {
-
padding: 0.75rem 1.5rem;
-
border-radius: 6px;
-
font-size: 1rem;
-
font-weight: 500;
-
cursor: pointer;
-
transition: all 0.2s;
-
font-family: inherit;
-
border: 2px solid;
-
text-decoration: none;
-
display: inline-block;
-
}
-
-
.btn-primary {
-
background: var(--primary);
-
color: white;
-
border-color: var(--primary);
-
}
-
-
.btn-primary:hover {
-
background: transparent;
-
color: var(--primary);
-
}
-
-
.btn-secondary {
-
background: transparent;
-
color: var(--text);
-
border-color: var(--secondary);
-
}
-
-
.btn-secondary:hover {
-
border-color: var(--primary);
-
color: var(--primary);
-
}
-
-
@media (max-width: 640px) {
-
.hero-title {
-
font-size: 2.5rem;
-
}
-
-
.cta-buttons {
-
flex-direction: column;
-
align-items: center;
-
}
-
}
-
</style>
+
<link rel="stylesheet" href="../styles/index.css">
</head>
<body>
···
</main>
<script type="module" src="../components/auth.ts"></script>
-
<script type="module">
-
document.getElementById('start-btn').addEventListener('click', async () => {
-
const authComponent = document.querySelector('auth-component');
-
const isLoggedIn = await authComponent.isAuthenticated();
-
-
if (isLoggedIn) {
-
window.location.href = '/classes';
-
} else {
-
authComponent.openAuthModal();
-
}
-
});
-
</script>
+
<script type="module" src="./index.ts"></script>
</body>
</html>
+10
src/pages/index.ts
···
+
document.getElementById('start-btn')?.addEventListener('click', async () => {
+
const authComponent = document.querySelector('auth-component') as any;
+
const isLoggedIn = await authComponent.isAuthenticated();
+
+
if (isLoggedIn) {
+
window.location.href = '/classes';
+
} else {
+
authComponent.openAuthModal();
+
}
+
});
+3 -20
src/pages/reset-password.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<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'">
<title>Reset Password - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
-
<style>
-
main {
-
display: flex;
-
align-items: center;
-
justify-content: center;
-
padding: 4rem 1rem;
-
}
-
</style>
+
<link rel="stylesheet" href="../styles/reset-password.css">
</head>
<body>
···
<script type="module" src="../components/auth.ts"></script>
<script type="module" src="../components/reset-password-form.ts"></script>
-
<script type="module">
-
// Wait for component to be defined before setting token
-
await customElements.whenDefined('reset-password-form');
-
-
// Get token from URL and pass to component
-
const urlParams = new URLSearchParams(window.location.search);
-
const token = urlParams.get('token');
-
const resetForm = document.getElementById('reset-form');
-
if (resetForm) {
-
resetForm.token = token;
-
}
-
</script>
+
<script type="module" src="./reset-password.ts"></script>
</body>
</html>
+10
src/pages/reset-password.ts
···
+
// Wait for component to be defined before setting token
+
await customElements.whenDefined('reset-password-form');
+
+
// Get token from URL and pass to component
+
const urlParams = new URLSearchParams(window.location.search);
+
const token = urlParams.get('token');
+
const resetForm = document.getElementById('reset-form') as any;
+
if (resetForm) {
+
resetForm.token = token;
+
}
+2 -6
src/pages/settings.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<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'">
<title>Settings - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
-
-
<style>
-
main {
-
max-width: 64rem;
-
}
-
</style>
+
<link rel="stylesheet" href="../styles/settings.css">
</head>
<body>
+4 -21
src/pages/transcribe.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<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'">
<title>Transcribe - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
<link rel="manifest" href="../../public/favicon/site.webmanifest">
<link rel="stylesheet" href="../styles/main.css">
-
<style>
-
.page-header {
-
text-align: center;
-
margin-bottom: 3rem;
-
}
-
-
.page-title {
-
font-size: 2.5rem;
-
font-weight: 700;
-
color: var(--text);
-
margin-bottom: 0.5rem;
-
}
-
-
.page-subtitle {
-
font-size: 1.125rem;
-
color: var(--text);
-
opacity: 0.8;
-
}
-
</style>
+
<link rel="stylesheet" href="../styles/transcribe.css">
</head>
<body>
···
</header>
<main>
-
<div style="margin-bottom: 1rem;">
-
<a href="/classes" style="color: var(--paynes-gray); text-decoration: none; font-size: 0.875rem;">
+
<div class="mb-1">
+
<a href="/classes" class="back-link">
← Back to classes
</a>
</div>
+120
src/styles/admin.css
···
+
main {
+
max-width: 80rem;
+
margin: 0 auto;
+
padding: 2rem;
+
}
+
+
h1 {
+
margin-bottom: 2rem;
+
color: var(--text);
+
}
+
+
.section {
+
margin-bottom: 3rem;
+
}
+
+
.section-title {
+
font-size: 1.5rem;
+
font-weight: 600;
+
color: var(--text);
+
margin-bottom: 1rem;
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.tabs {
+
display: flex;
+
gap: 1rem;
+
border-bottom: 2px solid var(--secondary);
+
margin-bottom: 2rem;
+
}
+
+
.tab {
+
padding: 0.75rem 1.5rem;
+
border: none;
+
background: transparent;
+
color: var(--text);
+
cursor: pointer;
+
font-size: 1rem;
+
font-weight: 500;
+
font-family: inherit;
+
border-bottom: 2px solid transparent;
+
margin-bottom: -2px;
+
transition: all 0.2s;
+
}
+
+
.tab:hover {
+
color: var(--primary);
+
}
+
+
.tab.active {
+
color: var(--primary);
+
border-bottom-color: var(--primary);
+
}
+
+
.tab-content {
+
display: none;
+
}
+
+
.tab-content.active {
+
display: block;
+
}
+
+
.empty-state {
+
text-align: center;
+
padding: 3rem;
+
color: var(--text);
+
opacity: 0.6;
+
}
+
+
.loading {
+
text-align: center;
+
padding: 3rem;
+
color: var(--text);
+
}
+
+
.error {
+
background: #fee2e2;
+
color: #991b1b;
+
padding: 1rem;
+
border-radius: 6px;
+
margin-bottom: 1rem;
+
}
+
+
.stats {
+
display: grid;
+
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+
gap: 1rem;
+
margin-bottom: 2rem;
+
}
+
+
.stat-card {
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 8px;
+
padding: 1.5rem;
+
}
+
+
.stat-value {
+
font-size: 2rem;
+
font-weight: 700;
+
color: var(--primary);
+
margin-bottom: 0.25rem;
+
}
+
+
.stat-label {
+
color: var(--text);
+
opacity: 0.7;
+
font-size: 0.875rem;
+
}
+
+
.timestamp {
+
color: var(--text);
+
opacity: 0.6;
+
font-size: 0.875rem;
+
}
+
+
.hidden {
+
display: none;
+
}
+71
src/styles/index.css
···
+
.hero-title {
+
font-size: 3rem;
+
font-weight: 700;
+
color: var(--text);
+
margin-bottom: 1rem;
+
}
+
+
.hero-subtitle {
+
font-size: 1.25rem;
+
color: var(--text);
+
opacity: 0.8;
+
margin-bottom: 2rem;
+
}
+
+
main {
+
text-align: center;
+
padding: 4rem 2rem;
+
}
+
+
.cta-buttons {
+
display: flex;
+
gap: 1rem;
+
justify-content: center;
+
margin-top: 2rem;
+
}
+
+
.btn {
+
padding: 0.75rem 1.5rem;
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
border: 2px solid;
+
text-decoration: none;
+
display: inline-block;
+
}
+
+
.btn-primary {
+
background: var(--primary);
+
color: white;
+
border-color: var(--primary);
+
}
+
+
.btn-primary:hover {
+
background: transparent;
+
color: var(--primary);
+
}
+
+
.btn-secondary {
+
background: transparent;
+
color: var(--text);
+
border-color: var(--secondary);
+
}
+
+
.btn-secondary:hover {
+
border-color: var(--primary);
+
color: var(--primary);
+
}
+
+
@media (max-width: 640px) {
+
.hero-title {
+
font-size: 2.5rem;
+
}
+
+
.cta-buttons {
+
flex-direction: column;
+
align-items: center;
+
}
+
}
+6
src/styles/reset-password.css
···
+
main {
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
padding: 4rem 1rem;
+
}
+3
src/styles/settings.css
···
+
main {
+
max-width: 64rem;
+
}
+27
src/styles/transcribe.css
···
+
.page-header {
+
text-align: center;
+
margin-bottom: 3rem;
+
}
+
+
.page-title {
+
font-size: 2.5rem;
+
font-weight: 700;
+
color: var(--text);
+
margin-bottom: 0.5rem;
+
}
+
+
.page-subtitle {
+
font-size: 1.125rem;
+
color: var(--text);
+
opacity: 0.8;
+
}
+
+
.back-link {
+
color: var(--paynes-gray);
+
text-decoration: none;
+
font-size: 0.875rem;
+
}
+
+
.mb-1 {
+
margin-bottom: 1rem;
+
}