blog/themes/stack/assets/ts/cookies.ts
2026-07-31 15:38:57 +08:00

223 lines
6.6 KiB
TypeScript

interface ConsentState {
necessary: boolean;
analytics: boolean;
functional: boolean;
timestamp: number;
}
class CookieConsent {
private static COOKIE_NAME = 'cookie_consent';
private static COOKIE_DAYS = 365;
private state: ConsentState | null = null;
private banner: HTMLElement | null = null;
private settingsPanel: HTMLElement | null = null;
constructor() {
this.banner = document.getElementById('cookie-consent-banner');
this.settingsPanel = document.getElementById('cookie-settings-panel');
this.state = this.loadState();
if (!this.state && this.banner) {
this.showBanner();
}
this.bindEvents();
this.dispatchConsentEvent();
}
private loadState(): ConsentState | null {
const cookie = document.cookie
.split('; ')
.find(row => row.startsWith(CookieConsent.COOKIE_NAME + '='));
if (!cookie) return null;
try {
return JSON.parse(decodeURIComponent(cookie.split('=')[1]));
} catch {
return null;
}
}
private saveState(): void {
if (!this.state) return;
const expires = new Date();
expires.setDate(expires.getDate() + CookieConsent.COOKIE_DAYS);
document.cookie = `${CookieConsent.COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.state))}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
}
private showBanner(): void {
if (this.banner) {
this.banner.removeAttribute('aria-hidden');
}
}
private hideBanner(): void {
if (this.banner) {
// Blur any focused element inside the banner before hiding
const activeElement = document.activeElement as HTMLElement;
if (activeElement && this.banner.contains(activeElement)) {
activeElement.blur();
}
this.banner.setAttribute('aria-hidden', 'true');
}
this.hideSettings();
}
private showSettings(): void {
if (this.settingsPanel) {
this.settingsPanel.removeAttribute('aria-hidden');
// Restore checkbox states from current state or defaults
const checkboxes = this.settingsPanel.querySelectorAll('input[data-cookie-category]');
checkboxes.forEach((cb) => {
const input = cb as HTMLInputElement;
const category = input.dataset.cookieCategory as keyof ConsentState;
if (category && this.state && typeof this.state[category] === 'boolean') {
input.checked = this.state[category] as boolean;
} else {
input.checked = false;
}
});
}
}
private hideSettings(): void {
if (this.settingsPanel) {
// Blur any focused element inside the settings panel before hiding
const activeElement = document.activeElement as HTMLElement;
if (activeElement && this.settingsPanel.contains(activeElement)) {
activeElement.blur();
}
this.settingsPanel.setAttribute('aria-hidden', 'true');
}
}
private bindEvents(): void {
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const action = target.dataset.cookieAction;
if (!action) return;
switch (action) {
case 'accept':
this.acceptAll();
break;
case 'deny':
this.denyAll();
break;
case 'settings':
this.showSettings();
break;
case 'save':
this.saveSettings();
break;
case 'cancel':
this.hideSettings();
break;
case 'reopen':
this.showBanner();
break;
}
});
}
private acceptAll(): void {
this.state = {
necessary: true,
analytics: true,
functional: true,
timestamp: Date.now()
};
this.saveState();
this.hideBanner();
this.dispatchConsentEvent();
}
private denyAll(): void {
this.state = {
necessary: true,
analytics: false,
functional: false,
timestamp: Date.now()
};
this.saveState();
this.hideBanner();
this.dispatchConsentEvent();
}
private saveSettings(): void {
const checkboxes = document.querySelectorAll('input[data-cookie-category]');
this.state = {
necessary: true,
analytics: false,
functional: false,
timestamp: Date.now()
};
checkboxes.forEach((cb) => {
const input = cb as HTMLInputElement;
const category = input.dataset.cookieCategory as keyof ConsentState;
if (category && category in this.state!) {
(this.state as any)[category] = input.checked;
}
});
this.saveState();
this.hideBanner();
this.dispatchConsentEvent();
}
private dispatchConsentEvent(): void {
// Cross-module event consumed by consent-gated features (for example commentsConsent.ts).
const event = new CustomEvent('onCookieConsentChange', {
detail: this.state
});
window.dispatchEvent(event);
// Set data attributes on document for CSS-based control
if (this.state) {
document.documentElement.dataset.consentAnalytics = String(this.state.analytics);
document.documentElement.dataset.consentFunctional = String(this.state.functional);
}
}
// Public API
public hasConsent(category: keyof Omit<ConsentState, 'timestamp'>): boolean {
if (!this.state) return false;
return this.state[category] ?? false;
}
public getState(): ConsentState | null {
return this.state;
}
public reopenBanner(): void {
this.showBanner();
}
}
// Export for module usage
export default CookieConsent;
// Initialize when DOM is ready and expose globally
declare global {
interface Window {
cookieConsent: CookieConsent;
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
window.cookieConsent = new CookieConsent();
});
} else {
window.cookieConsent = new CookieConsent();
}