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:
parent
fac7754d7a
commit
d7f3c55a2a
60 changed files with 3214 additions and 20 deletions
50
app/wwwroot/css/app-layout.css
Normal file
50
app/wwwroot/css/app-layout.css
Normal 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
118
app/wwwroot/css/base.css
Normal 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);
|
||||
}
|
||||
163
app/wwwroot/css/design-system.css
Normal file
163
app/wwwroot/css/design-system.css
Normal 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
258
app/wwwroot/css/drawers.css
Normal 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
204
app/wwwroot/css/page.css
Normal 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
246
app/wwwroot/css/sidebar.css
Normal 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
258
app/wwwroot/css/stats.css
Normal 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
180
app/wwwroot/css/topbar.css
Normal 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);
|
||||
}
|
||||
BIN
app/wwwroot/fonts/Poppins-Black.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-Black.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-BlackItalic.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-BlackItalic.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-Bold.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-Bold.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-BoldItalic.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-BoldItalic.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-ExtraBold.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-ExtraBold.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-ExtraBoldItalic.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-ExtraBoldItalic.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-ExtraLight.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-ExtraLight.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-ExtraLightItalic.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-ExtraLightItalic.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-Italic.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-Italic.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-Light.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-Light.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-LightItalic.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-LightItalic.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-Medium.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-Medium.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-MediumItalic.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-MediumItalic.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-Regular.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-Regular.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-SemiBold.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-SemiBold.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-SemiBoldItalic.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-SemiBoldItalic.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-Thin.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-Thin.woff
Normal file
Binary file not shown.
BIN
app/wwwroot/fonts/Poppins-ThinItalic.woff
Normal file
BIN
app/wwwroot/fonts/Poppins-ThinItalic.woff
Normal file
Binary file not shown.
58
app/wwwroot/ts/app.ts
Normal file
58
app/wwwroot/ts/app.ts
Normal 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;
|
||||
226
app/wwwroot/ts/modules/drawers.ts
Normal file
226
app/wwwroot/ts/modules/drawers.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
182
app/wwwroot/ts/modules/lockscreen.ts
Normal file
182
app/wwwroot/ts/modules/lockscreen.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
106
app/wwwroot/ts/modules/search.ts
Normal file
106
app/wwwroot/ts/modules/search.ts
Normal 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
|
||||
}));
|
||||
}
|
||||
}
|
||||
96
app/wwwroot/ts/modules/sidebar.ts
Normal file
96
app/wwwroot/ts/modules/sidebar.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
120
app/wwwroot/ts/modules/theme.ts
Normal file
120
app/wwwroot/ts/modules/theme.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
22
app/wwwroot/ts/tsconfig.json
Normal file
22
app/wwwroot/ts/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue