tangled
alpha
login
or
join now
dunkirk.sh
/
thistle
1
fork
atom
🪻 distributed transcription service
thistle.dunkirk.sh
1
fork
atom
overview
issues
pulls
pipelines
chore: fix biome lint issues and format
dunkirk.sh
3 months ago
b30c6519
d024a754
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+285
-259
14 changed files
expand all
collapse all
unified
split
src
index.ts
lib
api-response-format.test.ts
auth.ts
classes.ts
cursor.test.ts
pagination.test.ts
pages
admin.ts
index.ts
reset-password.ts
styles
admin.css
index.css
reset-password.css
settings.css
transcribe.css
+27
-16
src/index.ts
···
135
135
console.warn(
136
136
"[Startup] ORIGIN not set, defaulting to http://localhost:3000",
137
137
);
138
138
-
console.warn(
139
139
-
"[Startup] Set ORIGIN in production for correct email links",
140
140
-
);
138
138
+
console.warn("[Startup] Set ORIGIN in production for correct email links");
141
139
}
142
140
143
141
console.log("[Startup] Environment variable validation passed");
···
641
639
}),
642
640
});
643
641
644
644
-
return Response.json({ success: true, message: "Verification email sent" });
642
642
+
return Response.json({
643
643
+
success: true,
644
644
+
message: "Verification email sent",
645
645
+
});
645
646
} catch (error) {
646
647
return handleError(error);
647
648
}
···
829
830
await updateUserPassword(userId, password);
830
831
consumePasswordResetToken(token);
831
832
832
832
-
return Response.json({ success: true, message: "Password reset successfully" });
833
833
+
return Response.json({
834
834
+
success: true,
835
835
+
message: "Password reset successfully",
836
836
+
});
833
837
} catch (error) {
834
838
console.error("[Email] Reset password error:", error);
835
839
return Response.json(
···
898
902
try {
899
903
const user = requireAuth(req);
900
904
901
901
-
const rateLimitError = enforceRateLimit(req, "passkey-register-options", {
902
902
-
ip: { max: 10, windowSeconds: 5 * 60 },
903
903
-
});
905
905
+
const rateLimitError = enforceRateLimit(
906
906
+
req,
907
907
+
"passkey-register-options",
908
908
+
{
909
909
+
ip: { max: 10, windowSeconds: 5 * 60 },
910
910
+
},
911
911
+
);
904
912
if (rateLimitError) return rateLimitError;
905
913
906
914
const options = await createRegistrationOptions(user);
···
915
923
try {
916
924
const _user = requireAuth(req);
917
925
918
918
-
const rateLimitError = enforceRateLimit(req, "passkey-register-verify", {
919
919
-
ip: { max: 10, windowSeconds: 5 * 60 },
920
920
-
});
926
926
+
const rateLimitError = enforceRateLimit(
927
927
+
req,
928
928
+
"passkey-register-verify",
929
929
+
{
930
930
+
ip: { max: 10, windowSeconds: 5 * 60 },
931
931
+
},
932
932
+
);
921
933
if (rateLimitError) return rateLimitError;
922
934
923
935
const body = await req.json();
···
2195
2207
const { encodeCursor } = await import("./lib/cursor");
2196
2208
const last = transcriptions[transcriptions.length - 1];
2197
2209
if (last) {
2198
2198
-
nextCursor = encodeCursor([
2199
2199
-
last.created_at.toString(),
2200
2200
-
last.id,
2201
2201
-
]);
2210
2210
+
nextCursor = encodeCursor([last.created_at.toString(), last.id]);
2202
2211
}
2203
2212
}
2204
2213
···
3440
3449
server.stop();
3441
3450
3442
3451
// 2. Close all active SSE streams (safe to kill - sync will handle reconnection)
3443
3443
-
console.log(`[Shutdown] Closing ${activeSSEStreams.size} active SSE streams...`);
3452
3452
+
console.log(
3453
3453
+
`[Shutdown] Closing ${activeSSEStreams.size} active SSE streams...`,
3454
3454
+
);
3444
3455
for (const controller of activeSSEStreams) {
3445
3456
try {
3446
3457
controller.close();
+1
-1
src/lib/api-response-format.test.ts
···
2
2
3
3
/**
4
4
* API Response Format Standards
5
5
-
*
5
5
+
*
6
6
* This test documents the standardized response formats across the API.
7
7
* All endpoints should follow these patterns for consistency.
8
8
*/
+4
-1
src/lib/auth.ts
···
759
759
const { encodeCursor } = require("./cursor");
760
760
const last = users[users.length - 1];
761
761
if (last) {
762
762
-
nextCursor = encodeCursor([last.created_at.toString(), last.id.toString()]);
762
762
+
nextCursor = encodeCursor([
763
763
+
last.created_at.toString(),
764
764
+
last.id.toString(),
765
765
+
]);
763
766
}
764
767
}
765
768
+4
-1
src/lib/classes.ts
···
99
99
const { year, semester, courseCode, id } = decodeClassCursor(cursor);
100
100
101
101
classes = db
102
102
-
.query<ClassWithStats, [number, number, string, string, string, number]>(
102
102
+
.query<
103
103
+
ClassWithStats,
104
104
+
[number, number, string, string, string, number]
105
105
+
>(
103
106
`SELECT c.* FROM classes c
104
107
INNER JOIN class_members cm ON c.id = cm.class_id
105
108
WHERE cm.user_id = ? AND
+3
-3
src/lib/cursor.test.ts
···
1
1
import { describe, expect, test } from "bun:test";
2
2
import {
3
3
-
encodeCursor,
3
3
+
decodeClassCursor,
4
4
decodeCursor,
5
5
-
encodeSimpleCursor,
6
5
decodeSimpleCursor,
7
6
encodeClassCursor,
8
8
-
decodeClassCursor,
7
7
+
encodeCursor,
8
8
+
encodeSimpleCursor,
9
9
} from "./cursor";
10
10
11
11
describe("Cursor encoding/decoding", () => {
+3
-13
src/lib/pagination.test.ts
···
1
1
-
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
2
1
import { Database } from "bun:sqlite";
2
2
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3
3
4
4
let testDb: Database;
5
5
···
171
171
// Create test users
172
172
testDb.run(
173
173
"INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",
174
174
-
[
175
175
-
"user1@test.com",
176
176
-
"hash1",
177
177
-
Math.floor(Date.now() / 1000) - 100,
178
178
-
"user",
179
179
-
],
174
174
+
["user1@test.com", "hash1", Math.floor(Date.now() / 1000) - 100, "user"],
180
175
);
181
176
testDb.run(
182
177
"INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",
183
183
-
[
184
184
-
"user2@test.com",
185
185
-
"hash2",
186
186
-
Math.floor(Date.now() / 1000) - 50,
187
187
-
"user",
188
188
-
],
178
178
+
["user2@test.com", "hash2", Math.floor(Date.now() / 1000) - 50, "user"],
189
179
);
190
180
testDb.run(
191
181
"INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",
+104
-89
src/pages/admin.ts
···
1
1
-
const transcriptionsComponent = document.getElementById('transcriptions-component') as any;
2
2
-
const usersComponent = document.getElementById('users-component') as any;
3
3
-
const userModal = document.getElementById('user-modal') as any;
4
4
-
const transcriptModal = document.getElementById('transcript-modal') as any;
5
5
-
const errorMessage = document.getElementById('error-message') as HTMLElement;
6
6
-
const loading = document.getElementById('loading') as HTMLElement;
7
7
-
const content = document.getElementById('content') as HTMLElement;
1
1
+
const transcriptionsComponent = document.getElementById(
2
2
+
"transcriptions-component",
3
3
+
) as HTMLElement | null;
4
4
+
const usersComponent = document.getElementById(
5
5
+
"users-component",
6
6
+
) as HTMLElement | null;
7
7
+
const userModal = document.getElementById("user-modal") as HTMLElement | null;
8
8
+
const transcriptModal = document.getElementById(
9
9
+
"transcript-modal",
10
10
+
) as HTMLElement | null;
11
11
+
const errorMessage = document.getElementById("error-message") as HTMLElement;
12
12
+
const loading = document.getElementById("loading") as HTMLElement;
13
13
+
const content = document.getElementById("content") as HTMLElement;
8
14
9
15
// Modal functions
10
16
function openUserModal(userId: string) {
11
11
-
userModal.setAttribute('open', '');
12
12
-
userModal.userId = userId;
17
17
+
userModal.setAttribute("open", "");
18
18
+
userModal.userId = userId;
13
19
}
14
20
15
21
function closeUserModal() {
16
16
-
userModal.removeAttribute('open');
17
17
-
userModal.userId = null;
22
22
+
userModal.removeAttribute("open");
23
23
+
userModal.userId = null;
18
24
}
19
25
20
26
function openTranscriptModal(transcriptId: string) {
21
21
-
transcriptModal.setAttribute('open', '');
22
22
-
transcriptModal.transcriptId = transcriptId;
27
27
+
transcriptModal.setAttribute("open", "");
28
28
+
transcriptModal.transcriptId = transcriptId;
23
29
}
24
30
25
31
function closeTranscriptModal() {
26
26
-
transcriptModal.removeAttribute('open');
27
27
-
transcriptModal.transcriptId = null;
32
32
+
transcriptModal.removeAttribute("open");
33
33
+
transcriptModal.transcriptId = null;
28
34
}
29
35
30
36
// Listen for component events
31
31
-
transcriptionsComponent?.addEventListener('open-transcription', (e: CustomEvent) => {
32
32
-
openTranscriptModal(e.detail.id);
33
33
-
});
37
37
+
transcriptionsComponent?.addEventListener(
38
38
+
"open-transcription",
39
39
+
(e: CustomEvent) => {
40
40
+
openTranscriptModal(e.detail.id);
41
41
+
},
42
42
+
);
34
43
35
35
-
usersComponent?.addEventListener('open-user', (e: CustomEvent) => {
36
36
-
openUserModal(e.detail.id);
44
44
+
usersComponent?.addEventListener("open-user", (e: CustomEvent) => {
45
45
+
openUserModal(e.detail.id);
37
46
});
38
47
39
48
// Listen for modal close events
40
40
-
userModal?.addEventListener('close', closeUserModal);
41
41
-
userModal?.addEventListener('user-updated', async () => {
42
42
-
await loadStats();
49
49
+
userModal?.addEventListener("close", closeUserModal);
50
50
+
userModal?.addEventListener("user-updated", async () => {
51
51
+
await loadStats();
43
52
});
44
44
-
userModal?.addEventListener('click', (e: MouseEvent) => {
45
45
-
if (e.target === userModal) closeUserModal();
53
53
+
userModal?.addEventListener("click", (e: MouseEvent) => {
54
54
+
if (e.target === userModal) closeUserModal();
46
55
});
47
56
48
48
-
transcriptModal?.addEventListener('close', closeTranscriptModal);
49
49
-
transcriptModal?.addEventListener('transcript-deleted', async () => {
50
50
-
await loadStats();
57
57
+
transcriptModal?.addEventListener("close", closeTranscriptModal);
58
58
+
transcriptModal?.addEventListener("transcript-deleted", async () => {
59
59
+
await loadStats();
51
60
});
52
52
-
transcriptModal?.addEventListener('click', (e: MouseEvent) => {
53
53
-
if (e.target === transcriptModal) closeTranscriptModal();
61
61
+
transcriptModal?.addEventListener("click", (e: MouseEvent) => {
62
62
+
if (e.target === transcriptModal) closeTranscriptModal();
54
63
});
55
64
56
65
async function loadStats() {
57
57
-
try {
58
58
-
const [transcriptionsRes, usersRes] = await Promise.all([
59
59
-
fetch('/api/admin/transcriptions'),
60
60
-
fetch('/api/admin/users')
61
61
-
]);
66
66
+
try {
67
67
+
const [transcriptionsRes, usersRes] = await Promise.all([
68
68
+
fetch("/api/admin/transcriptions"),
69
69
+
fetch("/api/admin/users"),
70
70
+
]);
62
71
63
63
-
if (!transcriptionsRes.ok || !usersRes.ok) {
64
64
-
if (transcriptionsRes.status === 403 || usersRes.status === 403) {
65
65
-
window.location.href = '/';
66
66
-
return;
67
67
-
}
68
68
-
throw new Error('Failed to load admin data');
69
69
-
}
72
72
+
if (!transcriptionsRes.ok || !usersRes.ok) {
73
73
+
if (transcriptionsRes.status === 403 || usersRes.status === 403) {
74
74
+
window.location.href = "/";
75
75
+
return;
76
76
+
}
77
77
+
throw new Error("Failed to load admin data");
78
78
+
}
79
79
+
80
80
+
const transcriptions = await transcriptionsRes.json();
81
81
+
const users = await usersRes.json();
82
82
+
83
83
+
const totalUsers = document.getElementById("total-users");
84
84
+
const totalTranscriptions = document.getElementById("total-transcriptions");
85
85
+
const failedTranscriptions = document.getElementById(
86
86
+
"failed-transcriptions",
87
87
+
);
70
88
71
71
-
const transcriptions = await transcriptionsRes.json();
72
72
-
const users = await usersRes.json();
89
89
+
if (totalUsers) totalUsers.textContent = users.length.toString();
90
90
+
if (totalTranscriptions)
91
91
+
totalTranscriptions.textContent = transcriptions.length.toString();
73
92
74
74
-
const totalUsers = document.getElementById('total-users');
75
75
-
const totalTranscriptions = document.getElementById('total-transcriptions');
76
76
-
const failedTranscriptions = document.getElementById('failed-transcriptions');
77
77
-
78
78
-
if (totalUsers) totalUsers.textContent = users.length.toString();
79
79
-
if (totalTranscriptions) totalTranscriptions.textContent = transcriptions.length.toString();
80
80
-
81
81
-
const failed = transcriptions.filter((t: any) => t.status === 'failed');
82
82
-
if (failedTranscriptions) failedTranscriptions.textContent = failed.length.toString();
93
93
+
const failed = transcriptions.filter(
94
94
+
(t: { status: string }) => t.status === "failed",
95
95
+
);
96
96
+
if (failedTranscriptions)
97
97
+
failedTranscriptions.textContent = failed.length.toString();
83
98
84
84
-
loading.classList.add('hidden');
85
85
-
content.classList.remove('hidden');
86
86
-
} catch (error) {
87
87
-
errorMessage.textContent = (error as Error).message;
88
88
-
errorMessage.classList.remove('hidden');
89
89
-
loading.classList.add('hidden');
90
90
-
}
99
99
+
loading.classList.add("hidden");
100
100
+
content.classList.remove("hidden");
101
101
+
} catch (error) {
102
102
+
errorMessage.textContent = (error as Error).message;
103
103
+
errorMessage.classList.remove("hidden");
104
104
+
loading.classList.add("hidden");
105
105
+
}
91
106
}
92
107
93
108
// Tab switching
94
109
function switchTab(tabName: string) {
95
95
-
document.querySelectorAll('.tab').forEach(t => {
96
96
-
t.classList.remove('active');
97
97
-
});
98
98
-
document.querySelectorAll('.tab-content').forEach(c => {
99
99
-
c.classList.remove('active');
100
100
-
});
110
110
+
document.querySelectorAll(".tab").forEach((t) => {
111
111
+
t.classList.remove("active");
112
112
+
});
113
113
+
document.querySelectorAll(".tab-content").forEach((c) => {
114
114
+
c.classList.remove("active");
115
115
+
});
101
116
102
102
-
const tabButton = document.querySelector(`[data-tab="${tabName}"]`);
103
103
-
const tabContent = document.getElementById(`${tabName}-tab`);
104
104
-
105
105
-
if (tabButton && tabContent) {
106
106
-
tabButton.classList.add('active');
107
107
-
tabContent.classList.add('active');
108
108
-
109
109
-
// Update URL without reloading
110
110
-
const url = new URL(window.location.href);
111
111
-
url.searchParams.set('tab', tabName);
112
112
-
// Remove subtab param when leaving classes tab
113
113
-
if (tabName !== 'classes') {
114
114
-
url.searchParams.delete('subtab');
115
115
-
}
116
116
-
window.history.pushState({}, '', url);
117
117
-
}
117
117
+
const tabButton = document.querySelector(`[data-tab="${tabName}"]`);
118
118
+
const tabContent = document.getElementById(`${tabName}-tab`);
119
119
+
120
120
+
if (tabButton && tabContent) {
121
121
+
tabButton.classList.add("active");
122
122
+
tabContent.classList.add("active");
123
123
+
124
124
+
// Update URL without reloading
125
125
+
const url = new URL(window.location.href);
126
126
+
url.searchParams.set("tab", tabName);
127
127
+
// Remove subtab param when leaving classes tab
128
128
+
if (tabName !== "classes") {
129
129
+
url.searchParams.delete("subtab");
130
130
+
}
131
131
+
window.history.pushState({}, "", url);
132
132
+
}
118
133
}
119
134
120
120
-
document.querySelectorAll('.tab').forEach(tab => {
121
121
-
tab.addEventListener('click', () => {
122
122
-
switchTab((tab as HTMLElement).dataset.tab || '');
123
123
-
});
135
135
+
document.querySelectorAll(".tab").forEach((tab) => {
136
136
+
tab.addEventListener("click", () => {
137
137
+
switchTab((tab as HTMLElement).dataset.tab || "");
138
138
+
});
124
139
});
125
140
126
141
// Check for tab query parameter on load
127
142
const params = new URLSearchParams(window.location.search);
128
128
-
const initialTab = params.get('tab');
129
129
-
const validTabs = ['pending', 'transcriptions', 'users', 'classes'];
143
143
+
const initialTab = params.get("tab");
144
144
+
const validTabs = ["pending", "transcriptions", "users", "classes"];
130
145
131
146
if (initialTab && validTabs.includes(initialTab)) {
132
132
-
switchTab(initialTab);
147
147
+
switchTab(initialTab);
133
148
}
134
149
135
150
// Initialize
+12
-8
src/pages/index.ts
···
1
1
-
document.getElementById('start-btn')?.addEventListener('click', async () => {
2
2
-
const authComponent = document.querySelector('auth-component') as any;
3
3
-
const isLoggedIn = await authComponent.isAuthenticated();
1
1
+
document.getElementById("start-btn")?.addEventListener("click", async () => {
2
2
+
const authComponent = document.querySelector("auth-component");
3
3
+
if (!authComponent) return;
4
4
5
5
-
if (isLoggedIn) {
6
6
-
window.location.href = '/classes';
7
7
-
} else {
8
8
-
authComponent.openAuthModal();
9
9
-
}
5
5
+
const isLoggedIn = await (
6
6
+
authComponent as { isAuthenticated: () => Promise<boolean> }
7
7
+
).isAuthenticated();
8
8
+
9
9
+
if (isLoggedIn) {
10
10
+
window.location.href = "/classes";
11
11
+
} else {
12
12
+
(authComponent as { openAuthModal: () => void }).openAuthModal();
13
13
+
}
10
14
});
+4
-4
src/pages/reset-password.ts
···
1
1
// Wait for component to be defined before setting token
2
2
-
await customElements.whenDefined('reset-password-form');
2
2
+
await customElements.whenDefined("reset-password-form");
3
3
4
4
// Get token from URL and pass to component
5
5
const urlParams = new URLSearchParams(window.location.search);
6
6
-
const token = urlParams.get('token');
7
7
-
const resetForm = document.getElementById('reset-form') as any;
6
6
+
const token = urlParams.get("token");
7
7
+
const resetForm = document.getElementById("reset-form");
8
8
if (resetForm) {
9
9
-
resetForm.token = token;
9
9
+
(resetForm as { token: string | null }).token = token;
10
10
}
+64
-64
src/styles/admin.css
···
1
1
main {
2
2
-
max-width: 80rem;
3
3
-
margin: 0 auto;
4
4
-
padding: 2rem;
2
2
+
max-width: 80rem;
3
3
+
margin: 0 auto;
4
4
+
padding: 2rem;
5
5
}
6
6
7
7
h1 {
8
8
-
margin-bottom: 2rem;
9
9
-
color: var(--text);
8
8
+
margin-bottom: 2rem;
9
9
+
color: var(--text);
10
10
}
11
11
12
12
.section {
13
13
-
margin-bottom: 3rem;
13
13
+
margin-bottom: 3rem;
14
14
}
15
15
16
16
.section-title {
17
17
-
font-size: 1.5rem;
18
18
-
font-weight: 600;
19
19
-
color: var(--text);
20
20
-
margin-bottom: 1rem;
21
21
-
display: flex;
22
22
-
align-items: center;
23
23
-
gap: 0.5rem;
17
17
+
font-size: 1.5rem;
18
18
+
font-weight: 600;
19
19
+
color: var(--text);
20
20
+
margin-bottom: 1rem;
21
21
+
display: flex;
22
22
+
align-items: center;
23
23
+
gap: 0.5rem;
24
24
}
25
25
26
26
.tabs {
27
27
-
display: flex;
28
28
-
gap: 1rem;
29
29
-
border-bottom: 2px solid var(--secondary);
30
30
-
margin-bottom: 2rem;
27
27
+
display: flex;
28
28
+
gap: 1rem;
29
29
+
border-bottom: 2px solid var(--secondary);
30
30
+
margin-bottom: 2rem;
31
31
}
32
32
33
33
.tab {
34
34
-
padding: 0.75rem 1.5rem;
35
35
-
border: none;
36
36
-
background: transparent;
37
37
-
color: var(--text);
38
38
-
cursor: pointer;
39
39
-
font-size: 1rem;
40
40
-
font-weight: 500;
41
41
-
font-family: inherit;
42
42
-
border-bottom: 2px solid transparent;
43
43
-
margin-bottom: -2px;
44
44
-
transition: all 0.2s;
34
34
+
padding: 0.75rem 1.5rem;
35
35
+
border: none;
36
36
+
background: transparent;
37
37
+
color: var(--text);
38
38
+
cursor: pointer;
39
39
+
font-size: 1rem;
40
40
+
font-weight: 500;
41
41
+
font-family: inherit;
42
42
+
border-bottom: 2px solid transparent;
43
43
+
margin-bottom: -2px;
44
44
+
transition: all 0.2s;
45
45
}
46
46
47
47
.tab:hover {
48
48
-
color: var(--primary);
48
48
+
color: var(--primary);
49
49
}
50
50
51
51
.tab.active {
52
52
-
color: var(--primary);
53
53
-
border-bottom-color: var(--primary);
52
52
+
color: var(--primary);
53
53
+
border-bottom-color: var(--primary);
54
54
}
55
55
56
56
.tab-content {
57
57
-
display: none;
57
57
+
display: none;
58
58
}
59
59
60
60
.tab-content.active {
61
61
-
display: block;
61
61
+
display: block;
62
62
}
63
63
64
64
.empty-state {
65
65
-
text-align: center;
66
66
-
padding: 3rem;
67
67
-
color: var(--text);
68
68
-
opacity: 0.6;
65
65
+
text-align: center;
66
66
+
padding: 3rem;
67
67
+
color: var(--text);
68
68
+
opacity: 0.6;
69
69
}
70
70
71
71
.loading {
72
72
-
text-align: center;
73
73
-
padding: 3rem;
74
74
-
color: var(--text);
72
72
+
text-align: center;
73
73
+
padding: 3rem;
74
74
+
color: var(--text);
75
75
}
76
76
77
77
.error {
78
78
-
background: #fee2e2;
79
79
-
color: #991b1b;
80
80
-
padding: 1rem;
81
81
-
border-radius: 6px;
82
82
-
margin-bottom: 1rem;
78
78
+
background: #fee2e2;
79
79
+
color: #991b1b;
80
80
+
padding: 1rem;
81
81
+
border-radius: 6px;
82
82
+
margin-bottom: 1rem;
83
83
}
84
84
85
85
.stats {
86
86
-
display: grid;
87
87
-
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
88
88
-
gap: 1rem;
89
89
-
margin-bottom: 2rem;
86
86
+
display: grid;
87
87
+
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
88
88
+
gap: 1rem;
89
89
+
margin-bottom: 2rem;
90
90
}
91
91
92
92
.stat-card {
93
93
-
background: var(--background);
94
94
-
border: 2px solid var(--secondary);
95
95
-
border-radius: 8px;
96
96
-
padding: 1.5rem;
93
93
+
background: var(--background);
94
94
+
border: 2px solid var(--secondary);
95
95
+
border-radius: 8px;
96
96
+
padding: 1.5rem;
97
97
}
98
98
99
99
.stat-value {
100
100
-
font-size: 2rem;
101
101
-
font-weight: 700;
102
102
-
color: var(--primary);
103
103
-
margin-bottom: 0.25rem;
100
100
+
font-size: 2rem;
101
101
+
font-weight: 700;
102
102
+
color: var(--primary);
103
103
+
margin-bottom: 0.25rem;
104
104
}
105
105
106
106
.stat-label {
107
107
-
color: var(--text);
108
108
-
opacity: 0.7;
109
109
-
font-size: 0.875rem;
107
107
+
color: var(--text);
108
108
+
opacity: 0.7;
109
109
+
font-size: 0.875rem;
110
110
}
111
111
112
112
.timestamp {
113
113
-
color: var(--text);
114
114
-
opacity: 0.6;
115
115
-
font-size: 0.875rem;
113
113
+
color: var(--text);
114
114
+
opacity: 0.6;
115
115
+
font-size: 0.875rem;
116
116
}
117
117
118
118
.hidden {
119
119
-
display: none;
119
119
+
display: none;
120
120
}
+41
-41
src/styles/index.css
···
1
1
.hero-title {
2
2
-
font-size: 3rem;
3
3
-
font-weight: 700;
4
4
-
color: var(--text);
5
5
-
margin-bottom: 1rem;
2
2
+
font-size: 3rem;
3
3
+
font-weight: 700;
4
4
+
color: var(--text);
5
5
+
margin-bottom: 1rem;
6
6
}
7
7
8
8
.hero-subtitle {
9
9
-
font-size: 1.25rem;
10
10
-
color: var(--text);
11
11
-
opacity: 0.8;
12
12
-
margin-bottom: 2rem;
9
9
+
font-size: 1.25rem;
10
10
+
color: var(--text);
11
11
+
opacity: 0.8;
12
12
+
margin-bottom: 2rem;
13
13
}
14
14
15
15
main {
16
16
-
text-align: center;
17
17
-
padding: 4rem 2rem;
16
16
+
text-align: center;
17
17
+
padding: 4rem 2rem;
18
18
}
19
19
20
20
.cta-buttons {
21
21
-
display: flex;
22
22
-
gap: 1rem;
23
23
-
justify-content: center;
24
24
-
margin-top: 2rem;
21
21
+
display: flex;
22
22
+
gap: 1rem;
23
23
+
justify-content: center;
24
24
+
margin-top: 2rem;
25
25
}
26
26
27
27
.btn {
28
28
-
padding: 0.75rem 1.5rem;
29
29
-
border-radius: 6px;
30
30
-
font-size: 1rem;
31
31
-
font-weight: 500;
32
32
-
cursor: pointer;
33
33
-
transition: all 0.2s;
34
34
-
font-family: inherit;
35
35
-
border: 2px solid;
36
36
-
text-decoration: none;
37
37
-
display: inline-block;
28
28
+
padding: 0.75rem 1.5rem;
29
29
+
border-radius: 6px;
30
30
+
font-size: 1rem;
31
31
+
font-weight: 500;
32
32
+
cursor: pointer;
33
33
+
transition: all 0.2s;
34
34
+
font-family: inherit;
35
35
+
border: 2px solid;
36
36
+
text-decoration: none;
37
37
+
display: inline-block;
38
38
}
39
39
40
40
.btn-primary {
41
41
-
background: var(--primary);
42
42
-
color: white;
43
43
-
border-color: var(--primary);
41
41
+
background: var(--primary);
42
42
+
color: white;
43
43
+
border-color: var(--primary);
44
44
}
45
45
46
46
.btn-primary:hover {
47
47
-
background: transparent;
48
48
-
color: var(--primary);
47
47
+
background: transparent;
48
48
+
color: var(--primary);
49
49
}
50
50
51
51
.btn-secondary {
52
52
-
background: transparent;
53
53
-
color: var(--text);
54
54
-
border-color: var(--secondary);
52
52
+
background: transparent;
53
53
+
color: var(--text);
54
54
+
border-color: var(--secondary);
55
55
}
56
56
57
57
.btn-secondary:hover {
58
58
-
border-color: var(--primary);
59
59
-
color: var(--primary);
58
58
+
border-color: var(--primary);
59
59
+
color: var(--primary);
60
60
}
61
61
62
62
@media (max-width: 640px) {
63
63
-
.hero-title {
64
64
-
font-size: 2.5rem;
65
65
-
}
63
63
+
.hero-title {
64
64
+
font-size: 2.5rem;
65
65
+
}
66
66
67
67
-
.cta-buttons {
68
68
-
flex-direction: column;
69
69
-
align-items: center;
70
70
-
}
67
67
+
.cta-buttons {
68
68
+
flex-direction: column;
69
69
+
align-items: center;
70
70
+
}
71
71
}
+4
-4
src/styles/reset-password.css
···
1
1
main {
2
2
-
display: flex;
3
3
-
align-items: center;
4
4
-
justify-content: center;
5
5
-
padding: 4rem 1rem;
2
2
+
display: flex;
3
3
+
align-items: center;
4
4
+
justify-content: center;
5
5
+
padding: 4rem 1rem;
6
6
}
+1
-1
src/styles/settings.css
···
1
1
main {
2
2
-
max-width: 64rem;
2
2
+
max-width: 64rem;
3
3
}
+13
-13
src/styles/transcribe.css
···
1
1
.page-header {
2
2
-
text-align: center;
3
3
-
margin-bottom: 3rem;
2
2
+
text-align: center;
3
3
+
margin-bottom: 3rem;
4
4
}
5
5
6
6
.page-title {
7
7
-
font-size: 2.5rem;
8
8
-
font-weight: 700;
9
9
-
color: var(--text);
10
10
-
margin-bottom: 0.5rem;
7
7
+
font-size: 2.5rem;
8
8
+
font-weight: 700;
9
9
+
color: var(--text);
10
10
+
margin-bottom: 0.5rem;
11
11
}
12
12
13
13
.page-subtitle {
14
14
-
font-size: 1.125rem;
15
15
-
color: var(--text);
16
16
-
opacity: 0.8;
14
14
+
font-size: 1.125rem;
15
15
+
color: var(--text);
16
16
+
opacity: 0.8;
17
17
}
18
18
19
19
.back-link {
20
20
-
color: var(--paynes-gray);
21
21
-
text-decoration: none;
22
22
-
font-size: 0.875rem;
20
20
+
color: var(--paynes-gray);
21
21
+
text-decoration: none;
22
22
+
font-size: 0.875rem;
23
23
}
24
24
25
25
.mb-1 {
26
26
-
margin-bottom: 1rem;
26
26
+
margin-bottom: 1rem;
27
27
}