Restructures project with feature-based organization

Refactors project structure to support modular, feature-driven development

Introduces comprehensive language localization support
Adds menu management with role-based access control
Implements dynamic sidebar and theme switching capabilities

Enhances project scalability and maintainability
This commit is contained in:
Janus C. H. Knudsen 2026-01-08 15:44:11 +01:00
parent fac7754d7a
commit d7f3c55a2a
60 changed files with 3214 additions and 20 deletions

View file

@ -0,0 +1,50 @@
/**
* App Layout - Main Grid Structure
*
* Definerer den overordnede app-struktur med sidebar og main content
*/
/* ===========================================
MAIN APP GRID
=========================================== */
swp-app-layout {
display: grid;
grid-template-columns: var(--side-menu-width) 1fr;
grid-template-rows: var(--topbar-height) 1fr;
height: 100vh;
transition: grid-template-columns var(--transition-normal);
}
/* ===========================================
COLLAPSED MENU STATE
=========================================== */
swp-app-layout.menu-collapsed {
grid-template-columns: var(--side-menu-width-collapsed) 1fr;
}
/* ===========================================
MAIN CONTENT AREA
=========================================== */
swp-main-content {
display: block;
overflow-y: auto;
background: var(--color-background);
}
/* ===========================================
DRAWER OVERLAY
=========================================== */
swp-drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: var(--z-overlay);
opacity: 0;
visibility: hidden;
transition: opacity var(--transition-normal), visibility var(--transition-normal);
}
swp-drawer-overlay.active {
opacity: 1;
visibility: visible;
}

118
app/wwwroot/css/base.css Normal file
View file

@ -0,0 +1,118 @@
/**
* Base Styles - Reset & Global Elements
*
* Normalization og grundlæggende styling
*/
/* ===========================================
FONT FACES
=========================================== */
@font-face {
font-family: 'Poppins';
src: url('../fonts/Poppins-Regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('../fonts/Poppins-Medium.woff') format('woff');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('../fonts/Poppins-SemiBold.woff') format('woff');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('../fonts/Poppins-Bold.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* ===========================================
RESET
=========================================== */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
}
/* ===========================================
BASE ELEMENTS
=========================================== */
body {
font-family: var(--font-family);
font-size: var(--font-size-base);
color: var(--color-text);
background: var(--color-background);
line-height: var(--line-height-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Links */
a {
color: var(--color-teal);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
text-decoration: underline;
}
/* Lists */
ul, ol {
list-style: none;
}
/* Images */
img {
max-width: 100%;
height: auto;
display: block;
}
/* Buttons */
button {
font-family: inherit;
cursor: pointer;
}
/* Inputs */
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
}
/* Focus visible */
:focus-visible {
outline: 2px solid var(--color-teal);
outline-offset: 2px;
}
/* Selection */
::selection {
background: var(--color-teal-light);
color: var(--color-text);
}

View file

@ -0,0 +1,163 @@
/**
* SWP Design System - CSS Variables
*
* Dette er den centrale definition af alle design tokens.
* Alle farver, fonts og layout-variabler defineres her.
*/
/* ===========================================
COLOR PALETTE - Light Mode (Default)
=========================================== */
:root {
/* Surfaces */
--color-surface: #fff;
--color-background: #f5f5f5;
--color-background-hover: #f0f0f0;
--color-background-alt: #fafafa;
/* Borders */
--color-border: #e0e0e0;
--color-border-light: #f0f0f0;
/* Text */
--color-text: #333;
--color-text-secondary: #666;
--color-text-muted: #999;
/* Brand Colors */
--color-teal: #00897b;
--color-teal-light: color-mix(in srgb, var(--color-teal) 10%, transparent);
/* Semantic Colors */
--color-blue: #1976d2;
--color-green: #43a047;
--color-amber: #f59e0b;
--color-red: #e53935;
--color-purple: #8b5cf6;
}
/* ===========================================
COLOR PALETTE - Dark Mode (System)
=========================================== */
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) {
--color-surface: #1e1e1e;
--color-background: #121212;
--color-background-hover: #2a2a2a;
--color-background-alt: #1a1a1a;
--color-border: #333;
--color-border-light: #2a2a2a;
--color-text: #e0e0e0;
--color-text-secondary: #999;
--color-text-muted: #666;
--color-teal: #26a69a;
--color-blue: #42a5f5;
--color-green: #66bb6a;
--color-amber: #ffb74d;
--color-red: #ef5350;
--color-purple: #a78bfa;
}
}
/* ===========================================
COLOR PALETTE - Dark Mode (Manual)
=========================================== */
:root.dark-mode {
--color-surface: #1e1e1e;
--color-background: #121212;
--color-background-hover: #2a2a2a;
--color-background-alt: #1a1a1a;
--color-border: #333;
--color-border-light: #2a2a2a;
--color-text: #e0e0e0;
--color-text-secondary: #999;
--color-text-muted: #666;
--color-teal: #26a69a;
--color-blue: #42a5f5;
--color-green: #66bb6a;
--color-amber: #ffb74d;
--color-red: #ef5350;
--color-purple: #a78bfa;
}
/* ===========================================
TYPOGRAPHY
=========================================== */
:root {
--font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Font Sizes */
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-base: 14px;
--font-size-md: 13px;
--font-size-lg: 16px;
--font-size-xl: 22px;
/* Line Heights */
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
}
/* ===========================================
SPACING
=========================================== */
:root {
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-3: 12px;
--spacing-4: 16px;
--spacing-5: 20px;
--spacing-6: 24px;
--spacing-8: 32px;
}
/* ===========================================
LAYOUT
=========================================== */
:root {
--side-menu-width: 240px;
--side-menu-width-collapsed: 64px;
--topbar-height: 56px;
--page-max-width: 1400px;
--border-radius: 6px;
--border-radius-lg: 8px;
}
/* ===========================================
TRANSITIONS
=========================================== */
:root {
--transition-fast: 150ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
}
/* ===========================================
Z-INDEX LAYERS
=========================================== */
:root {
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 900;
--z-drawer: 1000;
--z-modal: 1100;
--z-tooltip: 1200;
}
/* ===========================================
SHADOWS
=========================================== */
:root {
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.15);
}

258
app/wwwroot/css/drawers.css Normal file
View file

@ -0,0 +1,258 @@
/**
* Drawers - Slide-in Panels
*
* Profile drawer, notifications drawer, etc.
*/
/* ===========================================
BASE DRAWER
=========================================== */
swp-profile-drawer,
swp-notification-drawer,
swp-todo-drawer {
position: fixed;
top: 0;
right: 0;
width: 320px;
height: 100vh;
background: var(--color-surface);
border-left: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
z-index: var(--z-drawer);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform var(--transition-normal);
}
swp-profile-drawer.active,
swp-notification-drawer.active,
swp-todo-drawer.active {
transform: translateX(0);
}
/* ===========================================
DRAWER HEADER
=========================================== */
swp-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-4) var(--spacing-5);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
swp-drawer-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
}
swp-drawer-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
border-radius: var(--border-radius);
cursor: pointer;
color: var(--color-text-secondary);
transition: all var(--transition-fast);
}
swp-drawer-close:hover {
background: var(--color-background-hover);
color: var(--color-text);
}
swp-drawer-close i {
font-size: 20px;
}
/* ===========================================
DRAWER CONTENT
=========================================== */
swp-drawer-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-5);
}
swp-drawer-divider {
height: 1px;
background: var(--color-border);
margin: var(--spacing-4) 0;
}
/* ===========================================
PROFILE SECTION
=========================================== */
swp-profile-section {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--spacing-4) 0;
}
swp-profile-avatar-large {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--color-teal);
color: white;
font-size: 24px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--spacing-3);
}
swp-profile-name-large {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
margin-bottom: var(--spacing-1);
}
swp-profile-email {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
/* ===========================================
DRAWER MENU
=========================================== */
swp-drawer-menu {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
swp-drawer-menu-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-3);
border-radius: var(--border-radius);
cursor: pointer;
transition: background var(--transition-fast);
color: var(--color-text);
}
swp-drawer-menu-item:hover {
background: var(--color-background-hover);
}
swp-drawer-menu-item i {
font-size: 20px;
color: var(--color-text-secondary);
}
/* ===========================================
THEME TOGGLE
=========================================== */
swp-theme-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-3);
border-radius: var(--border-radius);
background: var(--color-background);
}
swp-theme-label {
display: flex;
align-items: center;
gap: var(--spacing-3);
color: var(--color-text);
}
swp-theme-label i {
font-size: 20px;
color: var(--color-text-secondary);
}
swp-toggle-switch {
position: relative;
width: 44px;
height: 24px;
}
swp-toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
swp-toggle-slider {
position: absolute;
cursor: pointer;
inset: 0;
background: var(--color-border);
border-radius: 12px;
transition: background var(--transition-fast);
}
swp-toggle-slider::before {
content: '';
position: absolute;
width: 18px;
height: 18px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: transform var(--transition-fast);
}
swp-toggle-switch input:checked + swp-toggle-slider {
background: var(--color-teal);
}
swp-toggle-switch input:checked + swp-toggle-slider::before {
transform: translateX(20px);
}
/* ===========================================
DRAWER FOOTER
=========================================== */
swp-drawer-footer {
padding: var(--spacing-4) var(--spacing-5);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
swp-drawer-action {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
width: 100%;
padding: var(--spacing-3);
font-size: var(--font-size-base);
font-family: var(--font-family);
color: var(--color-text-secondary);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
cursor: pointer;
transition: all var(--transition-fast);
}
swp-drawer-action:hover {
background: var(--color-background-hover);
}
swp-drawer-action.logout:hover {
color: var(--color-red);
border-color: var(--color-red);
}
swp-drawer-action i {
font-size: 18px;
}

204
app/wwwroot/css/page.css Normal file
View file

@ -0,0 +1,204 @@
/**
* Page Layout - Content Area Structure
*
* Page container, headers, cards og grid layouts
*/
/* ===========================================
PAGE CONTAINER
=========================================== */
swp-page-container {
display: block;
max-width: var(--page-max-width);
margin: 0 auto;
padding: var(--spacing-6);
}
/* ===========================================
PAGE HEADER
=========================================== */
swp-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--spacing-6);
}
swp-page-title h1 {
font-size: 24px;
font-weight: 600;
color: var(--color-text);
margin-bottom: var(--spacing-1);
}
swp-page-title p {
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
swp-page-actions {
display: flex;
gap: var(--spacing-2);
}
/* ===========================================
CARDS
=========================================== */
swp-card {
display: block;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
margin-bottom: var(--spacing-5);
}
swp-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-4) var(--spacing-5);
border-bottom: 1px solid var(--color-border);
}
swp-card-title {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--font-size-base);
font-weight: 600;
color: var(--color-text);
}
swp-card-title i {
font-size: 20px;
color: var(--color-text-secondary);
}
swp-card-action {
font-size: var(--font-size-md);
color: var(--color-teal);
cursor: pointer;
transition: opacity var(--transition-fast);
}
swp-card-action:hover {
opacity: 0.8;
}
swp-card-content {
padding: var(--spacing-5);
}
/* ===========================================
DASHBOARD GRID
=========================================== */
swp-dashboard-grid {
display: grid;
grid-template-columns: 1fr 350px;
gap: var(--spacing-5);
}
swp-main-column {
display: flex;
flex-direction: column;
}
swp-side-column {
display: flex;
flex-direction: column;
}
/* ===========================================
AI INSIGHT
=========================================== */
swp-ai-insight {
display: block;
padding: var(--spacing-4) var(--spacing-5);
background: linear-gradient(135deg,
color-mix(in srgb, var(--color-purple) 8%, transparent),
color-mix(in srgb, var(--color-teal) 8%, transparent)
);
border-radius: var(--border-radius);
}
swp-ai-header {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-bottom: var(--spacing-2);
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-purple);
}
swp-ai-header i {
font-size: 16px;
}
swp-ai-text {
font-size: var(--font-size-base);
color: var(--color-text);
line-height: var(--line-height-relaxed);
}
/* ===========================================
QUICK ACTIONS
=========================================== */
swp-quick-actions {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
swp-quick-action-btn {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-3);
font-size: var(--font-size-base);
font-family: var(--font-family);
color: var(--color-text);
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
cursor: pointer;
transition: all var(--transition-fast);
}
swp-quick-action-btn:hover {
background: var(--color-background-hover);
border-color: var(--color-teal);
color: var(--color-teal);
}
swp-quick-action-btn i {
font-size: 18px;
}
/* ===========================================
RESPONSIVE
=========================================== */
@media (max-width: 1200px) {
swp-dashboard-grid {
grid-template-columns: 1fr;
}
swp-side-column {
order: -1;
}
}
@media (max-width: 768px) {
swp-page-container {
padding: var(--spacing-4);
}
swp-page-header {
flex-direction: column;
gap: var(--spacing-4);
}
swp-page-actions {
width: 100%;
}
}

246
app/wwwroot/css/sidebar.css Normal file
View file

@ -0,0 +1,246 @@
/**
* Sidebar - Side Menu Component
*
* Navigation sidebar med collapse-funktionalitet
*/
/* ===========================================
SIDE MENU CONTAINER
=========================================== */
swp-side-menu {
grid-row: 1 / -1;
display: flex;
flex-direction: column;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
overflow-y: auto;
overflow-x: hidden;
}
/* ===========================================
HEADER
=========================================== */
swp-side-menu-header {
display: flex;
align-items: center;
gap: 10px;
height: var(--topbar-height);
padding: 0 var(--spacing-4);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
swp-side-menu-header > i {
font-size: 26px;
color: var(--color-teal);
}
swp-side-menu-logo {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
}
/* Toggle Button */
swp-menu-toggle {
margin-left: auto;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
border-radius: var(--border-radius);
cursor: pointer;
color: var(--color-text-secondary);
transition: all var(--transition-fast);
}
swp-menu-toggle:hover {
background: var(--color-background-hover);
color: var(--color-text);
}
swp-menu-toggle i {
font-size: 18px;
color: inherit;
transition: transform var(--transition-normal);
}
/* ===========================================
NAVIGATION
=========================================== */
swp-side-menu-nav {
flex: 1;
padding: var(--spacing-3) 0;
overflow-y: auto;
}
swp-side-menu-group {
display: block;
margin-bottom: var(--spacing-2);
}
swp-side-menu-label {
display: block;
padding: var(--spacing-2) var(--spacing-4) 6px;
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
}
/* ===========================================
MENU ITEMS
=========================================== */
swp-side-menu-item,
a[is="swp-side-menu-item"] {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: 10px var(--spacing-4);
color: var(--color-text);
cursor: pointer;
transition: all var(--transition-fast);
border-left: 3px solid transparent;
text-decoration: none;
}
swp-side-menu-item:hover,
a[is="swp-side-menu-item"]:hover {
background: var(--color-background-hover);
text-decoration: none;
}
swp-side-menu-item[data-active="true"],
a[is="swp-side-menu-item"][data-active="true"] {
background: var(--color-teal-light);
border-left-color: var(--color-teal);
color: var(--color-teal);
font-weight: 500;
}
swp-side-menu-item i,
a[is="swp-side-menu-item"] i {
font-size: 20px;
flex-shrink: 0;
}
/* ===========================================
FOOTER
=========================================== */
swp-side-menu-footer {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-3) var(--spacing-4);
border-top: 1px solid var(--color-border);
margin-top: auto;
flex-shrink: 0;
}
swp-side-menu-action {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
padding: 10px;
font-size: var(--font-size-md);
color: var(--color-text-secondary);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
cursor: pointer;
transition: all var(--transition-fast);
font-family: var(--font-family);
}
swp-side-menu-action:hover {
background: var(--color-background-hover);
}
swp-side-menu-action.lock:hover {
color: var(--color-amber);
border-color: var(--color-amber);
}
swp-side-menu-action.logout:hover {
color: var(--color-red);
border-color: var(--color-red);
}
swp-side-menu-action i {
font-size: 18px;
}
/* ===========================================
COLLAPSED STATE
=========================================== */
swp-app-layout.menu-collapsed swp-side-menu {
overflow: visible;
}
swp-app-layout.menu-collapsed swp-side-menu-logo,
swp-app-layout.menu-collapsed swp-side-menu-label,
swp-app-layout.menu-collapsed swp-side-menu-item span,
swp-app-layout.menu-collapsed swp-side-menu-action span,
swp-app-layout.menu-collapsed a[is="swp-side-menu-item"] span {
display: none;
}
swp-app-layout.menu-collapsed swp-side-menu-header {
justify-content: center;
padding: 0;
}
swp-app-layout.menu-collapsed swp-menu-toggle {
margin-left: 0;
}
swp-app-layout.menu-collapsed swp-menu-toggle i {
transform: rotate(180deg);
}
swp-app-layout.menu-collapsed swp-side-menu-item,
swp-app-layout.menu-collapsed a[is="swp-side-menu-item"] {
justify-content: center;
padding: var(--spacing-3);
border-left: none;
}
swp-app-layout.menu-collapsed swp-side-menu-item[data-active="true"],
swp-app-layout.menu-collapsed a[is="swp-side-menu-item"][data-active="true"] {
border-left: none;
border-radius: var(--border-radius-lg);
margin: 0 var(--spacing-2);
}
swp-app-layout.menu-collapsed swp-side-menu-action {
justify-content: center;
padding: var(--spacing-3);
}
swp-app-layout.menu-collapsed swp-side-menu-footer {
padding: var(--spacing-3) var(--spacing-2);
}
/* ===========================================
TOOLTIP (Collapsed State)
=========================================== */
.swp-menu-tooltip {
position: fixed;
margin: 0;
padding: 6px var(--spacing-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
font-size: var(--font-size-md);
font-family: var(--font-family);
color: var(--color-text);
white-space: nowrap;
box-shadow: var(--shadow-md);
pointer-events: none;
z-index: var(--z-tooltip);
}

258
app/wwwroot/css/stats.css Normal file
View file

@ -0,0 +1,258 @@
/**
* Stats - Statistics Components
*
* Stat bars, cards, values og trends
*/
/* ===========================================
STATS CONTAINER (Grid/Bar/Row)
=========================================== */
swp-stats-bar,
swp-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-4);
margin-bottom: var(--spacing-5);
}
swp-stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-4);
margin-bottom: var(--spacing-5);
}
/* ===========================================
STAT CARD
=========================================== */
swp-stat-card {
display: flex;
flex-direction: column;
background: var(--color-surface);
border-radius: var(--border-radius-lg);
padding: var(--spacing-4) var(--spacing-5);
border: 1px solid var(--color-border);
}
swp-stat-box {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
padding: var(--spacing-4);
background: var(--color-surface);
border-radius: var(--border-radius);
border: 1px solid var(--color-border);
}
/* ===========================================
STAT VALUE
=========================================== */
swp-stat-value {
display: block;
font-size: 22px;
font-weight: 600;
font-family: var(--font-mono);
color: var(--color-text);
line-height: var(--line-height-tight);
}
/* Larger variant for emphasis */
swp-stat-card swp-stat-value,
swp-stat-box swp-stat-value {
font-size: 22px;
font-weight: 600;
font-family: var(--font-mono);
color: var(--color-text);
}
/* ===========================================
STAT LABEL
=========================================== */
swp-stat-label {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-top: var(--spacing-1);
}
swp-stat-box swp-stat-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
}
/* ===========================================
STAT SUBTITLE
=========================================== */
swp-stat-subtitle {
display: block;
font-size: 11px;
color: var(--color-text-muted);
margin-top: var(--spacing-1);
}
/* ===========================================
STAT TREND / CHANGE
=========================================== */
swp-stat-trend,
swp-stat-change {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
font-size: var(--font-size-xs);
margin-top: var(--spacing-2);
}
swp-stat-trend i,
swp-stat-change i {
font-size: 14px;
}
/* Trend Up (positive) */
swp-stat-trend.up,
swp-stat-change.positive {
color: var(--color-green);
}
/* Trend Down (negative) */
swp-stat-trend.down,
swp-stat-change.negative {
color: var(--color-red);
}
/* Neutral trend */
swp-stat-trend.neutral {
color: var(--color-text-secondary);
}
/* ===========================================
COLOR MODIFIERS
=========================================== */
/* Highlight (Primary/Teal) */
swp-stat-card.highlight swp-stat-value,
swp-stat-box.highlight swp-stat-value,
swp-stat-card.teal swp-stat-value {
color: var(--color-teal);
}
/* Success (Green) */
swp-stat-card.success swp-stat-value {
color: var(--color-green);
}
/* Warning (Amber) */
swp-stat-card.warning swp-stat-value,
swp-stat-card.amber swp-stat-value {
color: var(--color-amber);
}
/* Danger (Red) */
swp-stat-card.danger swp-stat-value,
swp-stat-card.negative swp-stat-value,
swp-stat-card.red swp-stat-value {
color: var(--color-red);
}
/* Purple */
swp-stat-card.purple swp-stat-value {
color: var(--color-purple);
}
/* ===========================================
HIGHLIGHT CARD (Filled Background)
=========================================== */
swp-stat-card.highlight.filled {
background: linear-gradient(135deg, var(--color-teal) 0%, #00695c 100%);
color: white;
border-color: transparent;
}
swp-stat-card.highlight.filled swp-stat-value {
color: white;
}
swp-stat-card.highlight.filled swp-stat-label {
color: rgba(255, 255, 255, 0.8);
}
swp-stat-card.highlight.filled swp-stat-change {
color: rgba(255, 255, 255, 0.9);
}
/* ===========================================
QUICK STATS (Compact Variant)
=========================================== */
swp-quick-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-3);
}
swp-quick-stat {
display: flex;
flex-direction: column;
text-align: center;
padding: var(--spacing-3);
background: var(--color-background);
border-radius: var(--border-radius);
}
swp-quick-stat swp-stat-value {
font-size: 18px;
}
swp-quick-stat swp-stat-label {
font-size: 11px;
margin-top: var(--spacing-1);
}
/* ===========================================
STAT ITEM (Inline Variant)
=========================================== */
swp-stat-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-2) var(--spacing-3);
background: var(--color-background);
border-radius: var(--border-radius);
}
swp-stat-item swp-stat-value {
font-size: 16px;
font-weight: 600;
}
swp-stat-item swp-stat-value.mono {
font-family: var(--font-mono);
}
swp-stat-item swp-stat-label {
font-size: 11px;
margin-top: 0;
}
/* ===========================================
RESPONSIVE
=========================================== */
@media (max-width: 1200px) {
swp-stats-bar,
swp-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
swp-stats-bar,
swp-stats-grid,
swp-stats-row {
grid-template-columns: 1fr;
}
swp-quick-stats {
grid-template-columns: repeat(2, 1fr);
}
}

180
app/wwwroot/css/topbar.css Normal file
View file

@ -0,0 +1,180 @@
/**
* Topbar - App Header Bar
*
* Search, notifications og profil-menu
*/
/* ===========================================
TOPBAR CONTAINER
=========================================== */
swp-app-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--spacing-5);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: var(--z-sticky);
}
/* ===========================================
SEARCH
=========================================== */
swp-topbar-search {
display: flex;
align-items: center;
gap: 10px;
padding: var(--spacing-2) var(--spacing-3);
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
width: 320px;
transition: border-color var(--transition-fast);
}
swp-topbar-search:focus-within {
border-color: var(--color-teal);
}
swp-topbar-search i {
font-size: 18px;
color: var(--color-text-secondary);
flex-shrink: 0;
}
swp-topbar-search input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: var(--font-size-md);
font-family: var(--font-family);
color: var(--color-text);
}
swp-topbar-search input::placeholder {
color: var(--color-text-secondary);
}
swp-topbar-search kbd {
font-size: var(--font-size-xs);
font-family: var(--font-mono);
padding: 2px 6px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-secondary);
}
/* ===========================================
ACTIONS
=========================================== */
swp-topbar-actions {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
/* Action Buttons */
swp-topbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border: none;
background: transparent;
border-radius: var(--border-radius);
cursor: pointer;
transition: background var(--transition-fast);
position: relative;
color: var(--color-text-secondary);
}
swp-topbar-btn:hover {
background: var(--color-background-hover);
}
swp-topbar-btn i {
font-size: 22px;
}
/* Notification Badge */
swp-notification-badge {
position: absolute;
top: 6px;
right: 6px;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
font-weight: 600;
background: var(--color-red);
color: white;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
/* Divider */
swp-topbar-divider {
width: 1px;
height: 24px;
background: var(--color-border);
margin: 0 var(--spacing-2);
}
/* ===========================================
PROFILE TRIGGER
=========================================== */
swp-topbar-profile {
display: flex;
align-items: center;
gap: 10px;
padding: 6px var(--spacing-3) 6px 6px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--border-radius);
cursor: pointer;
transition: all var(--transition-fast);
}
swp-topbar-profile:hover {
background: var(--color-background-hover);
border-color: var(--color-border);
}
swp-profile-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-teal);
color: white;
font-size: var(--font-size-sm);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
swp-profile-info {
display: flex;
flex-direction: column;
align-items: flex-start;
}
swp-profile-name {
font-size: var(--font-size-md);
font-weight: 500;
color: var(--color-text);
line-height: var(--line-height-tight);
}
swp-profile-role {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
line-height: var(--line-height-tight);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

58
app/wwwroot/ts/app.ts Normal file
View file

@ -0,0 +1,58 @@
/**
* Salon OS App
*
* Main application class that orchestrates all UI controllers
*/
import { SidebarController } from './modules/sidebar';
import { DrawerController } from './modules/drawers';
import { ThemeController } from './modules/theme';
import { SearchController } from './modules/search';
import { LockScreenController } from './modules/lockscreen';
/**
* Main application class
*/
export class App {
readonly sidebar: SidebarController;
readonly drawers: DrawerController;
readonly theme: ThemeController;
readonly search: SearchController;
readonly lockScreen: LockScreenController;
constructor() {
// Initialize controllers
this.sidebar = new SidebarController();
this.drawers = new DrawerController();
this.theme = new ThemeController();
this.search = new SearchController();
this.lockScreen = new LockScreenController(this.drawers);
}
}
/**
* Global app instance
*/
let app: App;
/**
* Initialize the application
*/
function init(): void {
app = new App();
// Expose to window for debugging
if (typeof window !== 'undefined') {
(window as unknown as { app: App }).app = app;
}
}
// Wait for DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
export { app };
export default App;

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();
}
}
}

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"outDir": "../js/app",
"rootDir": ".",
"sourceMap": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": [
"./**/*.ts"
],
"exclude": [
"node_modules"
]
}