This commit is contained in:
Janus C. H. Knudsen 2026-01-10 20:39:17 +01:00
parent 54b057886c
commit 7fc1ae0650
204 changed files with 4345 additions and 134 deletions

View file

@ -0,0 +1,226 @@
/**
* Drawer Controller
*
* Handles all drawer functionality including profile, notifications, and todo drawers
*/
export type DrawerName = 'profile' | 'notification' | 'todo' | 'newTodo';
export class DrawerController {
private profileDrawer: HTMLElement | null = null;
private notificationDrawer: HTMLElement | null = null;
private todoDrawer: HTMLElement | null = null;
private newTodoDrawer: HTMLElement | null = null;
private overlay: HTMLElement | null = null;
private activeDrawer: DrawerName | null = null;
constructor() {
this.profileDrawer = document.getElementById('profileDrawer');
this.notificationDrawer = document.getElementById('notificationDrawer');
this.todoDrawer = document.getElementById('todoDrawer');
this.newTodoDrawer = document.getElementById('newTodoDrawer');
this.overlay = document.getElementById('drawerOverlay');
this.setupListeners();
}
/**
* Get currently active drawer name
*/
get active(): DrawerName | null {
return this.activeDrawer;
}
/**
* Open a drawer by name
*/
open(name: DrawerName): void {
this.closeAll();
const drawer = this.getDrawer(name);
if (drawer && this.overlay) {
drawer.classList.add('active');
this.overlay.classList.add('active');
document.body.style.overflow = 'hidden';
this.activeDrawer = name;
}
}
/**
* Close a specific drawer
*/
close(name: DrawerName): void {
const drawer = this.getDrawer(name);
drawer?.classList.remove('active');
// Only hide overlay if no drawers are active
if (this.overlay && !document.querySelector('.active[class*="drawer"]')) {
this.overlay.classList.remove('active');
document.body.style.overflow = '';
}
if (this.activeDrawer === name) {
this.activeDrawer = null;
}
}
/**
* Close all drawers
*/
closeAll(): void {
[this.profileDrawer, this.notificationDrawer, this.todoDrawer, this.newTodoDrawer]
.forEach(drawer => drawer?.classList.remove('active'));
this.overlay?.classList.remove('active');
document.body.style.overflow = '';
this.activeDrawer = null;
}
/**
* Open profile drawer
*/
openProfile(): void {
this.open('profile');
}
/**
* Open notification drawer
*/
openNotification(): void {
this.open('notification');
}
/**
* Open todo drawer (slides on top of profile)
*/
openTodo(): void {
this.todoDrawer?.classList.add('active');
}
/**
* Close todo drawer
*/
closeTodo(): void {
this.todoDrawer?.classList.remove('active');
this.closeNewTodo();
}
/**
* Open new todo drawer
*/
openNewTodo(): void {
this.newTodoDrawer?.classList.add('active');
}
/**
* Close new todo drawer
*/
closeNewTodo(): void {
this.newTodoDrawer?.classList.remove('active');
}
/**
* Mark all notifications as read
*/
markAllNotificationsRead(): void {
if (!this.notificationDrawer) return;
const unreadItems = this.notificationDrawer.querySelectorAll<HTMLElement>(
'swp-notification-item[data-unread="true"]'
);
unreadItems.forEach(item => item.removeAttribute('data-unread'));
const badge = document.querySelector<HTMLElement>('swp-notification-badge');
if (badge) {
badge.style.display = 'none';
}
}
private getDrawer(name: DrawerName): HTMLElement | null {
switch (name) {
case 'profile': return this.profileDrawer;
case 'notification': return this.notificationDrawer;
case 'todo': return this.todoDrawer;
case 'newTodo': return this.newTodoDrawer;
}
}
private setupListeners(): void {
// Profile drawer triggers
document.getElementById('profileTrigger')
?.addEventListener('click', () => this.openProfile());
document.getElementById('drawerClose')
?.addEventListener('click', () => this.close('profile'));
// Notification drawer triggers
document.getElementById('notificationsBtn')
?.addEventListener('click', () => this.openNotification());
document.getElementById('notificationDrawerClose')
?.addEventListener('click', () => this.close('notification'));
document.getElementById('markAllRead')
?.addEventListener('click', () => this.markAllNotificationsRead());
// Todo drawer triggers
document.getElementById('openTodoDrawer')
?.addEventListener('click', () => this.openTodo());
document.getElementById('todoDrawerBack')
?.addEventListener('click', () => this.closeTodo());
// New todo drawer triggers
document.getElementById('addTodoBtn')
?.addEventListener('click', () => this.openNewTodo());
document.getElementById('newTodoDrawerBack')
?.addEventListener('click', () => this.closeNewTodo());
document.getElementById('cancelNewTodo')
?.addEventListener('click', () => this.closeNewTodo());
document.getElementById('saveNewTodo')
?.addEventListener('click', () => this.closeNewTodo());
// Overlay click closes all
this.overlay?.addEventListener('click', () => this.closeAll());
// Escape key closes all
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') this.closeAll();
});
// Todo interactions
this.todoDrawer?.addEventListener('click', (e) => this.handleTodoClick(e));
// Visibility options
document.addEventListener('click', (e) => this.handleVisibilityClick(e));
}
private handleTodoClick(e: Event): void {
const target = e.target as HTMLElement;
const todoItem = target.closest<HTMLElement>('swp-todo-item');
const checkbox = target.closest<HTMLElement>('swp-todo-checkbox');
if (checkbox && todoItem) {
const isCompleted = todoItem.dataset.completed === 'true';
if (isCompleted) {
todoItem.removeAttribute('data-completed');
} else {
todoItem.dataset.completed = 'true';
}
}
// Toggle section collapse
const sectionHeader = target.closest<HTMLElement>('swp-todo-section-header');
if (sectionHeader) {
const section = sectionHeader.closest<HTMLElement>('swp-todo-section');
section?.classList.toggle('collapsed');
}
}
private handleVisibilityClick(e: Event): void {
const target = e.target as HTMLElement;
const option = target.closest<HTMLElement>('swp-visibility-option');
if (option) {
document.querySelectorAll<HTMLElement>('swp-visibility-option')
.forEach(o => o.classList.remove('active'));
option.classList.add('active');
}
}
}

View file

@ -0,0 +1,182 @@
/**
* Lock Screen Controller
*
* Handles PIN-based lock screen functionality
*/
import { DrawerController } from './drawers';
export class LockScreenController {
private static readonly CORRECT_PIN = '1234'; // Demo PIN
private lockScreen: HTMLElement | null = null;
private pinInput: HTMLElement | null = null;
private pinKeypad: HTMLElement | null = null;
private lockTimeEl: HTMLElement | null = null;
private pinDigits: NodeListOf<HTMLElement> | null = null;
private currentPin = '';
private drawers: DrawerController | null = null;
constructor(drawers?: DrawerController) {
this.drawers = drawers ?? null;
this.lockScreen = document.getElementById('lockScreen');
this.pinInput = document.getElementById('pinInput');
this.pinKeypad = document.getElementById('pinKeypad');
this.lockTimeEl = document.getElementById('lockTime');
this.pinDigits = this.pinInput?.querySelectorAll<HTMLElement>('swp-pin-digit') ?? null;
this.setupListeners();
}
/**
* Check if lock screen is active
*/
get isActive(): boolean {
return this.lockScreen?.classList.contains('active') ?? false;
}
/**
* Show the lock screen
*/
show(): void {
this.drawers?.closeAll();
if (this.lockScreen) {
this.lockScreen.classList.add('active');
document.body.style.overflow = 'hidden';
}
this.currentPin = '';
this.updateDisplay();
// Update lock time
if (this.lockTimeEl) {
this.lockTimeEl.textContent = `Låst kl. ${this.formatTime()}`;
}
}
/**
* Hide the lock screen
*/
hide(): void {
if (this.lockScreen) {
this.lockScreen.classList.remove('active');
document.body.style.overflow = '';
}
this.currentPin = '';
this.updateDisplay();
}
private formatTime(): string {
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
}
private updateDisplay(): void {
if (!this.pinDigits) return;
this.pinDigits.forEach((digit, index) => {
digit.classList.remove('filled', 'error');
if (index < this.currentPin.length) {
digit.textContent = '•';
digit.classList.add('filled');
} else {
digit.textContent = '';
}
});
}
private showError(): void {
if (!this.pinDigits) return;
this.pinDigits.forEach(digit => digit.classList.add('error'));
// Shake animation
this.pinInput?.classList.add('shake');
setTimeout(() => {
this.currentPin = '';
this.updateDisplay();
this.pinInput?.classList.remove('shake');
}, 500);
}
private verify(): void {
if (this.currentPin === LockScreenController.CORRECT_PIN) {
this.hide();
} else {
this.showError();
}
}
private addDigit(digit: string): void {
if (this.currentPin.length >= 4) return;
this.currentPin += digit;
this.updateDisplay();
// Auto-verify when 4 digits entered
if (this.currentPin.length === 4) {
setTimeout(() => this.verify(), 200);
}
}
private removeDigit(): void {
if (this.currentPin.length === 0) return;
this.currentPin = this.currentPin.slice(0, -1);
this.updateDisplay();
}
private clearPin(): void {
this.currentPin = '';
this.updateDisplay();
}
private setupListeners(): void {
// Keypad click handler
this.pinKeypad?.addEventListener('click', (e) => this.handleKeypadClick(e));
// Keyboard input
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
// Lock button in sidebar
document.querySelector<HTMLElement>('swp-side-menu-action.lock')
?.addEventListener('click', () => this.show());
}
private handleKeypadClick(e: Event): void {
const target = e.target as HTMLElement;
const key = target.closest<HTMLElement>('swp-pin-key');
if (!key) return;
const digit = key.dataset.digit;
const action = key.dataset.action;
if (digit) {
this.addDigit(digit);
} else if (action === 'backspace') {
this.removeDigit();
} else if (action === 'clear') {
this.clearPin();
}
}
private handleKeyboard(e: KeyboardEvent): void {
if (!this.isActive) return;
// Prevent default to avoid other interactions
e.preventDefault();
if (e.key >= '0' && e.key <= '9') {
this.addDigit(e.key);
} else if (e.key === 'Backspace') {
this.removeDigit();
} else if (e.key === 'Escape') {
this.clearPin();
}
}
}

View file

@ -0,0 +1,106 @@
/**
* Search Controller
*
* Handles global search functionality and keyboard shortcuts
*/
export class SearchController {
private input: HTMLInputElement | null = null;
private container: HTMLElement | null = null;
constructor() {
this.input = document.getElementById('globalSearch') as HTMLInputElement | null;
this.container = document.querySelector<HTMLElement>('swp-topbar-search');
this.setupListeners();
}
/**
* Get current search value
*/
get value(): string {
return this.input?.value ?? '';
}
/**
* Set search value
*/
set value(val: string) {
if (this.input) {
this.input.value = val;
}
}
/**
* Focus the search input
*/
focus(): void {
this.input?.focus();
}
/**
* Blur the search input
*/
blur(): void {
this.input?.blur();
}
/**
* Clear the search input
*/
clear(): void {
this.value = '';
}
private setupListeners(): void {
// Keyboard shortcuts
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
// Input handlers
if (this.input) {
this.input.addEventListener('input', (e) => this.handleInput(e));
// Prevent form submission if wrapped in form
const form = this.input.closest('form');
form?.addEventListener('submit', (e) => this.handleSubmit(e));
}
}
private handleKeyboard(e: KeyboardEvent): void {
// Cmd/Ctrl + K to focus search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
this.focus();
return;
}
// Escape to blur search when focused
if (e.key === 'Escape' && document.activeElement === this.input) {
this.blur();
}
}
private handleInput(e: Event): void {
const target = e.target as HTMLInputElement;
const query = target.value.trim();
// Emit custom event for search
document.dispatchEvent(new CustomEvent('app:search', {
detail: { query },
bubbles: true
}));
}
private handleSubmit(e: Event): void {
e.preventDefault();
const query = this.value.trim();
if (!query) return;
// Emit custom event for search submit
document.dispatchEvent(new CustomEvent('app:search-submit', {
detail: { query },
bubbles: true
}));
}
}

View file

@ -0,0 +1,96 @@
/**
* Sidebar Controller
*
* Handles sidebar collapse/expand and tooltip functionality
*/
export class SidebarController {
private menuToggle: HTMLElement | null = null;
private appLayout: HTMLElement | null = null;
private menuTooltip: HTMLElement | null = null;
constructor() {
this.menuToggle = document.getElementById('menuToggle');
this.appLayout = document.querySelector('swp-app-layout');
this.menuTooltip = document.getElementById('menuTooltip');
this.setupListeners();
this.setupTooltips();
this.restoreState();
}
/**
* Check if sidebar is collapsed
*/
get isCollapsed(): boolean {
return this.appLayout?.classList.contains('menu-collapsed') ?? false;
}
/**
* Toggle sidebar collapsed state
*/
toggle(): void {
if (!this.appLayout) return;
this.appLayout.classList.toggle('menu-collapsed');
localStorage.setItem('sidebar-collapsed', String(this.isCollapsed));
}
/**
* Collapse the sidebar
*/
collapse(): void {
this.appLayout?.classList.add('menu-collapsed');
localStorage.setItem('sidebar-collapsed', 'true');
}
/**
* Expand the sidebar
*/
expand(): void {
this.appLayout?.classList.remove('menu-collapsed');
localStorage.setItem('sidebar-collapsed', 'false');
}
private setupListeners(): void {
this.menuToggle?.addEventListener('click', () => this.toggle());
}
private setupTooltips(): void {
if (!this.menuTooltip) return;
const menuItems = document.querySelectorAll<HTMLElement>('swp-side-menu-item[data-tooltip]');
menuItems.forEach(item => {
item.addEventListener('mouseenter', () => this.showTooltip(item));
item.addEventListener('mouseleave', () => this.hideTooltip());
});
}
private showTooltip(item: HTMLElement): void {
if (!this.isCollapsed || !this.menuTooltip) return;
const rect = item.getBoundingClientRect();
const tooltipText = item.dataset.tooltip;
if (!tooltipText) return;
this.menuTooltip.textContent = tooltipText;
this.menuTooltip.style.left = `${rect.right + 8}px`;
this.menuTooltip.style.top = `${rect.top + rect.height / 2}px`;
this.menuTooltip.style.transform = 'translateY(-50%)';
this.menuTooltip.showPopover();
}
private hideTooltip(): void {
this.menuTooltip?.hidePopover();
}
private restoreState(): void {
if (!this.appLayout) return;
if (localStorage.getItem('sidebar-collapsed') === 'true') {
this.appLayout.classList.add('menu-collapsed');
}
}
}

View file

@ -0,0 +1,120 @@
/**
* Theme Controller
*
* Handles dark/light mode switching and system preference detection
*/
export type Theme = 'light' | 'dark' | 'system';
export class ThemeController {
private static readonly STORAGE_KEY = 'theme-preference';
private static readonly DARK_CLASS = 'dark-mode';
private static readonly LIGHT_CLASS = 'light-mode';
private root: HTMLElement;
private themeOptions: NodeListOf<HTMLElement>;
constructor() {
this.root = document.documentElement;
this.themeOptions = document.querySelectorAll<HTMLElement>('swp-theme-option');
this.applyTheme(this.current);
this.updateUI();
this.setupListeners();
}
/**
* Get the current theme setting
*/
get current(): Theme {
const stored = localStorage.getItem(ThemeController.STORAGE_KEY) as Theme | null;
if (stored === 'dark' || stored === 'light' || stored === 'system') {
return stored;
}
return 'system';
}
/**
* Check if dark mode is currently active
*/
get isDark(): boolean {
return this.root.classList.contains(ThemeController.DARK_CLASS) ||
(this.systemPrefersDark && !this.root.classList.contains(ThemeController.LIGHT_CLASS));
}
/**
* Check if system prefers dark mode
*/
get systemPrefersDark(): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
/**
* Set theme and persist preference
*/
set(theme: Theme): void {
localStorage.setItem(ThemeController.STORAGE_KEY, theme);
this.applyTheme(theme);
this.updateUI();
}
/**
* Toggle between light and dark themes
*/
toggle(): void {
this.set(this.isDark ? 'light' : 'dark');
}
private applyTheme(theme: Theme): void {
this.root.classList.remove(ThemeController.DARK_CLASS, ThemeController.LIGHT_CLASS);
if (theme === 'dark') {
this.root.classList.add(ThemeController.DARK_CLASS);
} else if (theme === 'light') {
this.root.classList.add(ThemeController.LIGHT_CLASS);
}
// 'system' leaves both classes off, letting CSS media query handle it
}
private updateUI(): void {
if (!this.themeOptions) return;
const darkActive = this.isDark;
this.themeOptions.forEach(option => {
const theme = option.dataset.theme as Theme;
const isActive = (theme === 'dark' && darkActive) || (theme === 'light' && !darkActive);
option.classList.toggle('active', isActive);
});
}
private setupListeners(): void {
// Theme option clicks
this.themeOptions.forEach(option => {
option.addEventListener('click', (e) => this.handleOptionClick(e));
});
// System theme changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => this.handleSystemChange());
}
private handleOptionClick(e: Event): void {
const target = e.target as HTMLElement;
const option = target.closest<HTMLElement>('swp-theme-option');
if (option) {
const theme = option.dataset.theme as Theme;
if (theme) {
this.set(theme);
}
}
}
private handleSystemChange(): void {
// Only react to system changes if we're using system preference
if (this.current === 'system') {
this.updateUI();
}
}
}