🪻 distributed transcription service thistle.dunkirk.sh

feat: add subtab query param support

dunkirk.sh 6789cc46 ec5cb8d4

verified
Changed files
+159 -3
src
+26 -3
src/components/admin-classes.ts
···
override async connectedCallback() {
super.connectedCallback();
+
+
// Check for subtab query parameter
+
const params = new URLSearchParams(window.location.search);
+
const subtab = params.get("subtab");
+
if (subtab && this.isValidSubtab(subtab)) {
+
this.activeTab = subtab as "classes" | "waitlist";
+
} else {
+
// Set default subtab in URL if on classes tab
+
this.setActiveTab(this.activeTab);
+
}
+
await this.loadData();
}
+
private isValidSubtab(subtab: string): boolean {
+
return ["classes", "waitlist"].includes(subtab);
+
}
+
+
private setActiveTab(tab: "classes" | "waitlist") {
+
this.activeTab = tab;
+
// Update URL without reloading page
+
const url = new URL(window.location.href);
+
url.searchParams.set("subtab", tab);
+
window.history.pushState({}, "", url);
+
}
+
private async loadData() {
this.isLoading = true;
this.error = "";
···
<button
class="tab ${this.activeTab === "classes" ? "active" : ""}"
@click=${() => {
-
this.activeTab = "classes";
+
this.setActiveTab("classes");
}}
>
Classes
···
<button
class="tab ${this.activeTab === "waitlist" ? "active" : ""}"
@click=${() => {
-
this.activeTab = "waitlist";
+
this.setActiveTab("waitlist");
}}
>
Waitlist
···
await this.loadData();
-
this.activeTab = "classes";
+
this.setActiveTab("classes");
this.showModal = false;
this.approvingEntry = null;
this.meetingTimes = [];
+129
src/components/auth.ts
···
@state() passkeySupported = false;
@state() needsEmailVerification = false;
@state() verificationCode = "";
+
@state() resendCodeTimer = 0;
+
@state() resendingCode = false;
+
private resendInterval: number | null = null;
+
private codeSentAt: number | null = null; // Unix timestamp in seconds when code was sent
static override styles = css`
:host {
···
font-family: 'Monaco', 'Courier New', monospace;
}
+
.resend-link {
+
text-align: center;
+
margin-top: 1rem;
+
font-size: 0.875rem;
+
color: var(--text);
+
}
+
+
.resend-button {
+
background: none;
+
border: none;
+
color: var(--primary);
+
cursor: pointer;
+
text-decoration: underline;
+
font-size: 0.875rem;
+
padding: 0;
+
font-family: inherit;
+
}
+
+
.resend-button:hover:not(:disabled) {
+
color: var(--accent);
+
}
+
+
.resend-button:disabled {
+
color: var(--secondary);
+
cursor: not-allowed;
+
text-decoration: none;
+
}
+
.btn-secondary {
background: transparent;
color: var(--text);
···
this.needsEmailVerification = true;
this.password = "";
this.error = "";
+
this.startResendTimer(data.verification_code_sent_at);
return;
}
···
this.needsEmailVerification = true;
this.password = "";
this.error = "";
+
this.startResendTimer(data.verification_code_sent_at);
return;
}
···
}
}
+
private startResendTimer(sentAtTimestamp: number) {
+
// Use provided timestamp
+
this.codeSentAt = sentAtTimestamp;
+
+
// Clear existing interval if any
+
if (this.resendInterval !== null) {
+
clearInterval(this.resendInterval);
+
}
+
+
// Update timer based on elapsed time
+
const updateTimer = () => {
+
if (this.codeSentAt === null) return;
+
+
const now = Math.floor(Date.now() / 1000);
+
const elapsed = now - this.codeSentAt;
+
const remaining = Math.max(0, (5 * 60) - elapsed);
+
this.resendCodeTimer = remaining;
+
+
if (remaining <= 0) {
+
if (this.resendInterval !== null) {
+
clearInterval(this.resendInterval);
+
this.resendInterval = null;
+
}
+
}
+
};
+
+
// Update immediately
+
updateTimer();
+
+
// Then update every second
+
this.resendInterval = window.setInterval(updateTimer, 1000);
+
}
+
+
private async handleResendCode() {
+
this.error = "";
+
this.resendingCode = true;
+
+
try {
+
const response = await fetch("/api/auth/resend-verification-code", {
+
method: "POST",
+
headers: {
+
"Content-Type": "application/json",
+
},
+
body: JSON.stringify({
+
email: this.email,
+
}),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to resend code";
+
return;
+
}
+
+
// Start the 5-minute timer
+
this.startResendTimer(data.verification_code_sent_at);
+
} catch (error) {
+
this.error = error instanceof Error ? error.message : "An error occurred";
+
} finally {
+
this.resendingCode = false;
+
}
+
}
+
+
private formatTimer(seconds: number): string {
+
const mins = Math.floor(seconds / 60);
+
const secs = seconds % 60;
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
+
}
+
+
override disconnectedCallback() {
+
super.disconnectedCallback();
+
// Clean up timer when component is removed
+
if (this.resendInterval !== null) {
+
clearInterval(this.resendInterval);
+
this.resendInterval = null;
+
}
+
}
+
override render() {
if (this.loading) {
return html`<div class="loading">Loading...</div>`;
···
? html`<div class="error-message">${this.error}</div>`
: ""
}
+
+
<div class="resend-link">
+
${
+
this.resendCodeTimer > 0
+
? html`Resend code in ${this.formatTimer(this.resendCodeTimer)}`
+
: html`
+
<button
+
type="button"
+
class="resend-button"
+
@click=${this.handleResendCode}
+
?disabled=${this.resendingCode}
+
>
+
${this.resendingCode ? "Sending..." : "Resend code"}
+
</button>
+
`
+
}
+
</div>
<div class="modal-actions">
<button
+4
src/pages/admin.html
···
// 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);
}
}