Removes CalendarServer app and adds sales landing page

Deletes .NET server-side project structure and replaces it with a static HTML sales page for Plantempus

Removes server-side components including:
- Language and localization services
- Menu and user role models
- Controllers and routing configuration

Adds comprehensive marketing landing page with responsive design and interactive sections
This commit is contained in:
Janus C. H. Knudsen 2026-01-26 17:52:42 +01:00
parent d7f3c55a2a
commit 12e7594f30
60 changed files with 1962 additions and 3137 deletions

View file

@ -1,10 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>CalendarServer</RootNamespace>
</PropertyGroup>
</Project>

View file

@ -1,12 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace CalendarServer.Controllers;
public class HomeController : Controller
{
public IActionResult Index()
{
ViewData["Title"] = "Dashboard";
return View();
}
}

View file

@ -1,8 +0,0 @@
namespace CalendarServer.Features.Language.Models;
public class SupportedCulture
{
public required string Code { get; set; }
public required string Name { get; set; }
public required string NativeName { get; set; }
}

View file

@ -1,10 +0,0 @@
using CalendarServer.Features.Language.Models;
namespace CalendarServer.Features.Language.Services;
public interface ILocalizationService
{
string Get(string key, string? culture = null);
string CurrentCulture { get; }
IEnumerable<SupportedCulture> GetSupportedCultures();
}

View file

@ -1,48 +0,0 @@
using System.Text.Json;
using CalendarServer.Features.Language.Models;
namespace CalendarServer.Features.Language.Services;
public class JsonLocalizationService : ILocalizationService
{
private readonly string _translationsPath;
public JsonLocalizationService(IWebHostEnvironment env)
{
_translationsPath = Path.Combine(env.ContentRootPath, "Features", "Language", "Translations");
}
public string CurrentCulture => "en";
public string Get(string key, string? culture = null)
{
culture ??= CurrentCulture;
var filePath = Path.Combine(_translationsPath, $"{culture}.json");
if (!File.Exists(filePath))
return key;
var json = File.ReadAllText(filePath);
var doc = JsonDocument.Parse(json);
var parts = key.Split('.');
JsonElement current = doc.RootElement;
foreach (var part in parts)
{
if (!current.TryGetProperty(part, out current))
return key;
}
return current.GetString() ?? key;
}
public IEnumerable<SupportedCulture> GetSupportedCultures()
{
return new List<SupportedCulture>
{
new() { Code = "da", Name = "Danish", NativeName = "Dansk" },
new() { Code = "en", Name = "English", NativeName = "English" }
};
}
}

View file

@ -1,30 +0,0 @@
using Microsoft.AspNetCore.Razor.TagHelpers;
using CalendarServer.Features.Language.Services;
namespace CalendarServer.Features.Language.TagHelpers;
[HtmlTargetElement(Attributes = "localize")]
public class LocalizeTagHelper : TagHelper
{
private readonly ILocalizationService _localize;
public LocalizeTagHelper(ILocalizationService localize)
{
_localize = localize;
}
[HtmlAttributeName("localize")]
public string Key { get; set; } = string.Empty;
public override void Process(TagHelperContext context, TagHelperOutput output)
{
var translated = _localize.Get(Key);
if (!string.IsNullOrEmpty(translated) && translated != Key)
{
output.Content.SetContent(translated);
}
output.Attributes.RemoveAll("localize");
}
}

View file

@ -1,33 +0,0 @@
{
"menu": {
"home": "Dashboard",
"calendar": "Kalender",
"pos": "Kasse",
"products": "Produkter & Lager",
"suppliers": "Leverandører",
"customers": "Kunder",
"employees": "Medarbejdere",
"reports": "Statistik & Rapporter",
"settings": "Indstillinger",
"account": "Abonnement & Konto"
},
"groups": {
"dashboard": "Dashboard",
"data": "Data",
"analytics": "Analyse",
"system": "System"
},
"common": {
"save": "Gem",
"cancel": "Annuller",
"search": "Søg",
"close": "Luk",
"delete": "Slet",
"edit": "Rediger",
"add": "Tilføj"
},
"sidebar": {
"lockScreen": "Lås skærm",
"appName": "Salon OS"
}
}

View file

@ -1,33 +0,0 @@
{
"menu": {
"home": "Dashboard",
"calendar": "Calendar",
"pos": "Point of Sale",
"products": "Products & Inventory",
"suppliers": "Suppliers",
"customers": "Customers",
"employees": "Employees",
"reports": "Statistics & Reports",
"settings": "Settings",
"account": "Subscription & Account"
},
"groups": {
"dashboard": "Dashboard",
"data": "Data",
"analytics": "Analytics",
"system": "System"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"search": "Search",
"close": "Close",
"delete": "Delete",
"edit": "Edit",
"add": "Add"
},
"sidebar": {
"lockScreen": "Lock screen",
"appName": "Salon OS"
}
}

View file

@ -1,12 +0,0 @@
namespace CalendarServer.Features.Menu.Models;
/// <summary>
/// Represents a group of menu items (e.g., "Dashboard", "Data", "System").
/// </summary>
public class MenuGroup
{
public required string Id { get; set; }
public required string Label { get; set; }
public int SortOrder { get; set; }
public List<MenuItem> Items { get; set; } = new();
}

View file

@ -1,15 +0,0 @@
namespace CalendarServer.Features.Menu.Models;
/// <summary>
/// Represents a single menu item in the sidebar.
/// </summary>
public class MenuItem
{
public required string Id { get; set; }
public required string Label { get; set; }
public required string Icon { get; set; }
public required string Url { get; set; }
public UserRole MinimumRole { get; set; } = UserRole.Staff;
public int SortOrder { get; set; }
public bool IsActive { get; set; }
}

View file

@ -1,11 +0,0 @@
namespace CalendarServer.Features.Menu.Models;
/// <summary>
/// User roles for menu visibility. Higher value = more access.
/// </summary>
public enum UserRole
{
Staff = 0,
Manager = 1,
Admin = 2
}

View file

@ -1,14 +0,0 @@
using CalendarServer.Features.Menu.Models;
namespace CalendarServer.Features.Menu.Services;
/// <summary>
/// Service for retrieving menu structure based on user role.
/// </summary>
public interface IMenuService
{
/// <summary>
/// Get menu groups filtered by user role.
/// </summary>
List<MenuGroup> GetMenuForRole(UserRole role, string? currentUrl = null);
}

View file

@ -1,187 +0,0 @@
using CalendarServer.Features.Menu.Models;
using CalendarServer.Features.Language.Services;
namespace CalendarServer.Features.Menu.Services;
/// <summary>
/// Mock implementation of IMenuService with hardcoded menu data.
/// </summary>
public class MockMenuService : IMenuService
{
private readonly ILocalizationService _localize;
public MockMenuService(ILocalizationService localize)
{
_localize = localize;
}
public List<MenuGroup> GetMenuForRole(UserRole role, string? currentUrl = null)
{
var allGroups = GetAllMenuGroups();
return allGroups
.Select(g => new MenuGroup
{
Id = g.Id,
Label = _localize.Get($"groups.{g.Id}"),
SortOrder = g.SortOrder,
Items = g.Items
.Where(i => role >= i.MinimumRole)
.Select(i => new MenuItem
{
Id = i.Id,
Label = _localize.Get($"menu.{i.Id}"),
Icon = i.Icon,
Url = i.Url,
MinimumRole = i.MinimumRole,
SortOrder = i.SortOrder,
IsActive = currentUrl != null && i.Url.Equals(currentUrl, StringComparison.OrdinalIgnoreCase)
})
.OrderBy(i => i.SortOrder)
.ToList()
})
.Where(g => g.Items.Any())
.OrderBy(g => g.SortOrder)
.ToList();
}
private static List<MenuGroup> GetAllMenuGroups()
{
return new List<MenuGroup>
{
// DASHBOARD GROUP
new MenuGroup
{
Id = "dashboard",
Label = "Dashboard",
SortOrder = 1,
Items = new List<MenuItem>
{
new MenuItem
{
Id = "home",
Label = "Dashboard",
Icon = "ph-squares-four",
Url = "/",
MinimumRole = UserRole.Staff,
SortOrder = 1
},
new MenuItem
{
Id = "calendar",
Label = "Kalender",
Icon = "ph-calendar",
Url = "/poc-calendar.html",
MinimumRole = UserRole.Staff,
SortOrder = 2
},
new MenuItem
{
Id = "pos",
Label = "Kasse",
Icon = "ph-device-mobile",
Url = "/pos",
MinimumRole = UserRole.Staff,
SortOrder = 3
}
}
},
// DATA GROUP
new MenuGroup
{
Id = "data",
Label = "Data",
SortOrder = 2,
Items = new List<MenuItem>
{
new MenuItem
{
Id = "products",
Label = "Produkter & Lager",
Icon = "ph-package",
Url = "/poc-produkter.html",
MinimumRole = UserRole.Manager,
SortOrder = 1
},
new MenuItem
{
Id = "suppliers",
Label = "Leverandører",
Icon = "ph-truck",
Url = "/poc-leverandoerer.html",
MinimumRole = UserRole.Manager,
SortOrder = 2
},
new MenuItem
{
Id = "customers",
Label = "Kunder",
Icon = "ph-users",
Url = "/customers",
MinimumRole = UserRole.Staff,
SortOrder = 3
},
new MenuItem
{
Id = "employees",
Label = "Medarbejdere",
Icon = "ph-user",
Url = "/poc-medarbejdere.html",
MinimumRole = UserRole.Manager,
SortOrder = 4
}
}
},
// ANALYSE GROUP
new MenuGroup
{
Id = "analytics",
Label = "Analyse",
SortOrder = 3,
Items = new List<MenuItem>
{
new MenuItem
{
Id = "reports",
Label = "Statistik & Rapporter",
Icon = "ph-chart-bar",
Url = "/reports",
MinimumRole = UserRole.Manager,
SortOrder = 1
}
}
},
// SYSTEM GROUP
new MenuGroup
{
Id = "system",
Label = "System",
SortOrder = 4,
Items = new List<MenuItem>
{
new MenuItem
{
Id = "settings",
Label = "Indstillinger",
Icon = "ph-gear",
Url = "/poc-indstillinger.html",
MinimumRole = UserRole.Admin,
SortOrder = 1
},
new MenuItem
{
Id = "account",
Label = "Abonnement & Konto",
Icon = "ph-credit-card",
Url = "/poc-konto.html",
MinimumRole = UserRole.Admin,
SortOrder = 2
}
}
}
};
}
}

View file

@ -1,35 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using CalendarServer.Features.Menu.Models;
using CalendarServer.Features.Menu.Services;
namespace CalendarServer.Features.Menu;
/// <summary>
/// ViewComponent for rendering the side menu based on user role.
/// </summary>
public class SideMenuViewComponent : ViewComponent
{
private readonly IMenuService _menuService;
public SideMenuViewComponent(IMenuService menuService)
{
_menuService = menuService;
}
public IViewComponentResult Invoke(UserRole? role = null)
{
// Default to Admin for demo (in real app, get from auth)
var userRole = role ?? UserRole.Admin;
var currentUrl = HttpContext.Request.Path.Value;
var groups = _menuService.GetMenuForRole(userRole, currentUrl);
var viewModel = new SideMenuViewModel
{
Groups = groups,
CurrentUserRole = userRole
};
return View(viewModel);
}
}

View file

@ -1,12 +0,0 @@
using CalendarServer.Features.Menu.Models;
namespace CalendarServer.Features.Menu;
/// <summary>
/// ViewModel for the side menu partial view.
/// </summary>
public class SideMenuViewModel
{
public required List<MenuGroup> Groups { get; set; }
public UserRole CurrentUserRole { get; set; }
}

View file

@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using CalendarServer.Features.Menu.Services;
using CalendarServer.Features.Language.Services;
var builder = WebApplication.CreateBuilder(args);
// Add MVC services
builder.Services.AddControllersWithViews();
// Register application services
builder.Services.AddScoped<IMenuService, MockMenuService>();
builder.Services.AddScoped<ILocalizationService, JsonLocalizationService>();
var app = builder.Build();
// Serve static files from wwwroot
app.UseStaticFiles();
// Configure routing
app.UseRouting();
// Map MVC routes
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run("http://localhost:8000");

View file

@ -1,91 +0,0 @@
@{
ViewData["Title"] = "Dashboard";
}
<swp-page-container>
<!-- Stats Bar -->
<swp-stats-bar>
<swp-stat-card class="highlight">
<swp-stat-value>12</swp-stat-value>
<swp-stat-label>Bookinger i dag</swp-stat-label>
<swp-stat-trend class="up">
<i class="ph ph-check-circle"></i>
4 gennemført, 2 i gang
</swp-stat-trend>
</swp-stat-card>
<swp-stat-card class="success">
<swp-stat-value>8.450 kr</swp-stat-value>
<swp-stat-label>Forventet omsætning</swp-stat-label>
<swp-stat-trend class="up">
<i class="ph ph-trend-up"></i>
+12% vs. gennemsnit
</swp-stat-trend>
</swp-stat-card>
<swp-stat-card>
<swp-stat-value>78%</swp-stat-value>
<swp-stat-label>Belægningsgrad</swp-stat-label>
<swp-stat-trend class="up">
<i class="ph ph-trend-up"></i>
God kapacitet
</swp-stat-trend>
</swp-stat-card>
<swp-stat-card class="warning">
<swp-stat-value>4</swp-stat-value>
<swp-stat-label>Kræver opmærksomhed</swp-stat-label>
</swp-stat-card>
</swp-stats-bar>
<!-- Dashboard Content -->
<swp-dashboard-grid>
<swp-main-column>
<!-- AI Insight -->
<swp-card>
<swp-ai-insight>
<swp-ai-header>
<i class="ph ph-sparkle"></i>
<span>AI Analyse</span>
</swp-ai-header>
<swp-ai-text>
<strong>Godt i gang!</strong> 4 af 12 bookinger er gennemført. 2 er i gang nu, og 6 venter.
Forventet omsætning: <strong>8.450 kr</strong> allerede realiseret <strong>2.150 kr</strong>.
</swp-ai-text>
</swp-ai-insight>
</swp-card>
<!-- Today's Bookings Preview -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-calendar-check"></i>
Dagens bookinger
</swp-card-title>
<swp-card-action>Se alle</swp-card-action>
</swp-card-header>
<swp-card-content>
<p>Booking oversigt kommer her...</p>
</swp-card-content>
</swp-card>
</swp-main-column>
<swp-side-column>
<!-- Quick Actions -->
<swp-card>
<swp-card-header>
<swp-card-title>Hurtige handlinger</swp-card-title>
</swp-card-header>
<swp-card-content>
<swp-quick-actions>
<swp-quick-action-btn>
<i class="ph ph-plus"></i>
Ny booking
</swp-quick-action-btn>
<swp-quick-action-btn>
<i class="ph ph-user-plus"></i>
Ny kunde
</swp-quick-action-btn>
</swp-quick-actions>
</swp-card-content>
</swp-card>
</swp-side-column>
</swp-dashboard-grid>
</swp-page-container>

View file

@ -1,39 +0,0 @@
@model CalendarServer.Features.Menu.SideMenuViewModel
<swp-side-menu>
<swp-side-menu-header>
<i class="ph ph-squares-four"></i>
<swp-side-menu-logo localize="sidebar.appName">Salon OS</swp-side-menu-logo>
<swp-menu-toggle id="menuToggle">
<i class="ph ph-caret-left"></i>
</swp-menu-toggle>
</swp-side-menu-header>
<swp-side-menu-nav>
@foreach (var group in Model.Groups)
{
<swp-side-menu-group>
<swp-side-menu-label>@group.Label</swp-side-menu-label>
@foreach (var item in group.Items)
{
<a href="@item.Url" is="swp-side-menu-item"
data-active="@(item.IsActive ? "true" : "false")"
data-tooltip="@item.Label">
<i class="ph @item.Icon"></i>
<span>@item.Label</span>
</a>
}
</swp-side-menu-group>
}
</swp-side-menu-nav>
<swp-side-menu-footer>
<swp-side-menu-action class="lock" id="lockScreen" title="Lås skærm">
<i class="ph ph-lock"></i>
<span localize="sidebar.lockScreen">Lås skærm</span>
</swp-side-menu-action>
</swp-side-menu-footer>
</swp-side-menu>
<!-- Tooltip for collapsed menu -->
<span id="menuTooltip" class="swp-menu-tooltip" popover="manual"></span>

View file

@ -1,49 +0,0 @@
<swp-profile-drawer id="profileDrawer">
<swp-drawer-header>
<swp-drawer-title>Profil</swp-drawer-title>
<swp-drawer-close id="closeProfileDrawer">
<i class="ph ph-x"></i>
</swp-drawer-close>
</swp-drawer-header>
<swp-drawer-content>
<swp-profile-section>
<swp-profile-avatar-large>MJ</swp-profile-avatar-large>
<swp-profile-name-large>Maria Jensen</swp-profile-name-large>
<swp-profile-email>maria@salon.dk</swp-profile-email>
</swp-profile-section>
<swp-drawer-divider></swp-drawer-divider>
<swp-drawer-menu>
<swp-drawer-menu-item>
<i class="ph ph-user"></i>
<span>Min profil</span>
</swp-drawer-menu-item>
<swp-drawer-menu-item>
<i class="ph ph-gear"></i>
<span>Indstillinger</span>
</swp-drawer-menu-item>
</swp-drawer-menu>
<swp-drawer-divider></swp-drawer-divider>
<swp-theme-toggle>
<swp-theme-label>
<i class="ph ph-moon"></i>
<span>Mørk tilstand</span>
</swp-theme-label>
<swp-toggle-switch id="themeToggle">
<input type="checkbox" id="themeCheckbox">
<swp-toggle-slider></swp-toggle-slider>
</swp-toggle-switch>
</swp-theme-toggle>
</swp-drawer-content>
<swp-drawer-footer>
<swp-drawer-action class="logout" id="logoutBtn">
<i class="ph ph-sign-out"></i>
<span>Log ud</span>
</swp-drawer-action>
</swp-drawer-footer>
</swp-profile-drawer>

View file

@ -1,78 +0,0 @@
<swp-side-menu>
<swp-side-menu-header>
<i class="ph ph-squares-four"></i>
<swp-side-menu-logo>Salon OS</swp-side-menu-logo>
<swp-menu-toggle id="menuToggle">
<i class="ph ph-caret-left"></i>
</swp-menu-toggle>
</swp-side-menu-header>
<swp-side-menu-nav>
<!-- DASHBOARD -->
<swp-side-menu-group>
<swp-side-menu-label>Dashboard</swp-side-menu-label>
<a asp-controller="Home" asp-action="Index" is="swp-side-menu-item" data-tooltip="Dashboard">
<i class="ph ph-squares-four"></i>
<span>Dashboard</span>
</a>
<a href="/poc-calendar.html" is="swp-side-menu-item" data-tooltip="Kalender">
<i class="ph ph-calendar"></i>
<span>Kalender</span>
</a>
<a href="#" is="swp-side-menu-item" data-tooltip="Kasse">
<i class="ph ph-device-mobile"></i>
<span>Kasse</span>
</a>
</swp-side-menu-group>
<!-- DATA -->
<swp-side-menu-group>
<swp-side-menu-label>Data</swp-side-menu-label>
<a href="/poc-produkter.html" is="swp-side-menu-item" data-tooltip="Produkter & Lager">
<i class="ph ph-package"></i>
<span>Produkter & Lager</span>
</a>
<a href="/poc-leverandoerer.html" is="swp-side-menu-item" data-tooltip="Leverandører">
<i class="ph ph-truck"></i>
<span>Leverandører</span>
</a>
<a href="#" is="swp-side-menu-item" data-tooltip="Kunder">
<i class="ph ph-users"></i>
<span>Kunder</span>
</a>
<a href="/poc-medarbejdere.html" is="swp-side-menu-item" data-tooltip="Medarbejdere">
<i class="ph ph-user"></i>
<span>Medarbejdere</span>
</a>
</swp-side-menu-group>
<!-- ANALYSE -->
<swp-side-menu-group>
<swp-side-menu-label>Analyse</swp-side-menu-label>
<a href="#" is="swp-side-menu-item" data-tooltip="Statistik & Rapporter">
<i class="ph ph-chart-bar"></i>
<span>Statistik & Rapporter</span>
</a>
</swp-side-menu-group>
<!-- SYSTEM -->
<swp-side-menu-group>
<swp-side-menu-label>System</swp-side-menu-label>
<a href="/poc-indstillinger.html" is="swp-side-menu-item" data-tooltip="Indstillinger">
<i class="ph ph-gear"></i>
<span>Indstillinger</span>
</a>
<a href="/poc-konto.html" is="swp-side-menu-item" data-tooltip="Abonnement & Konto">
<i class="ph ph-credit-card"></i>
<span>Abonnement & Konto</span>
</a>
</swp-side-menu-group>
</swp-side-menu-nav>
<swp-side-menu-footer>
<swp-side-menu-action class="lock" id="lockScreen" title="Lås skærm">
<i class="ph ph-lock"></i>
<span>Lås skærm</span>
</swp-side-menu-action>
</swp-side-menu-footer>
</swp-side-menu>

View file

@ -1,26 +0,0 @@
<swp-app-topbar>
<swp-topbar-search>
<i class="ph ph-magnifying-glass"></i>
<input type="text" placeholder="Søg i Salon OS..." id="globalSearch">
<kbd>⌘K</kbd>
</swp-topbar-search>
<swp-topbar-actions>
<!-- Notifications -->
<swp-topbar-btn id="notificationsBtn" title="Notifikationer">
<i class="ph ph-bell"></i>
<swp-notification-badge>3</swp-notification-badge>
</swp-topbar-btn>
<swp-topbar-divider></swp-topbar-divider>
<!-- Profile (opens drawer) -->
<swp-topbar-profile id="profileTrigger">
<swp-profile-avatar>MJ</swp-profile-avatar>
<swp-profile-info>
<swp-profile-name>Maria Jensen</swp-profile-name>
<swp-profile-role>Administrator</swp-profile-role>
</swp-profile-info>
</swp-topbar-profile>
</swp-topbar-actions>
</swp-app-topbar>

View file

@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@ViewData["Title"] - Salon OS</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@@phosphor-icons/web@@2.1.2/src/regular/style.css" />
<!-- Design System -->
<link rel="stylesheet" href="~/css/design-system.css">
<link rel="stylesheet" href="~/css/base.css">
<!-- Layout Components -->
<link rel="stylesheet" href="~/css/app-layout.css">
<link rel="stylesheet" href="~/css/sidebar.css">
<link rel="stylesheet" href="~/css/topbar.css">
<link rel="stylesheet" href="~/css/drawers.css">
<!-- Page Components -->
<link rel="stylesheet" href="~/css/page.css">
<link rel="stylesheet" href="~/css/stats.css">
@await RenderSectionAsync("Styles", required: false)
</head>
<body>
<swp-app-layout id="appLayout">
@await Component.InvokeAsync("SideMenu")
@await Html.PartialAsync("Components/_TopBar")
<swp-main-content>
@RenderBody()
</swp-main-content>
</swp-app-layout>
@await Html.PartialAsync("Components/_ProfileDrawer")
<swp-drawer-overlay id="drawerOverlay"></swp-drawer-overlay>
<script type="module" src="~/js/app.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View file

@ -1,3 +0,0 @@
@using CalendarServer
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, CalendarServer

View file

@ -1,3 +0,0 @@
@{
Layout = "_Layout";
}

View file

@ -1,24 +0,0 @@
import * as esbuild from 'esbuild';
async function build() {
try {
await esbuild.build({
entryPoints: ['wwwroot/ts/app.ts'],
bundle: true,
outfile: 'wwwroot/js/app.js',
format: 'esm',
sourcemap: 'inline',
target: 'es2020',
minify: false,
keepNames: true,
platform: 'browser'
});
console.log('App bundle created: wwwroot/js/app.js');
} catch (error) {
console.error('Build failed:', error);
process.exit(1);
}
}
build();

View file

@ -1,50 +0,0 @@
/**
* 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;
}

View file

@ -1,118 +0,0 @@
/**
* 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

@ -1,163 +0,0 @@
/**
* 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);
}

View file

@ -1,258 +0,0 @@
/**
* 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;
}

View file

@ -1,204 +0,0 @@
/**
* 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%;
}
}

View file

@ -1,246 +0,0 @@
/**
* 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);
}

View file

@ -1,258 +0,0 @@
/**
* 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);
}
}

View file

@ -1,180 +0,0 @@
/**
* 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.

View file

@ -1,58 +0,0 @@
/**
* 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

@ -1,226 +0,0 @@
/**
* 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

@ -1,182 +0,0 @@
/**
* 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

@ -1,106 +0,0 @@
/**
* 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

@ -1,96 +0,0 @@
/**
* 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

@ -1,120 +0,0 @@
/**
* 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

@ -1,22 +0,0 @@
{
"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"
]
}

File diff suppressed because it is too large Load diff

View file

@ -3064,6 +3064,72 @@ Tak for din handel!</swp-edit-textarea>
</swp-module-footer> </swp-module-footer>
</swp-module-card> </swp-module-card>
<!-- AI Kundeanalyse -->
<swp-module-card class="featured purple">
<swp-module-header>
<swp-module-icon class="purple">
<i class="ph ph-users-three"></i>
</swp-module-icon>
<swp-module-info>
<swp-module-title>AI Kundeanalyse</swp-module-title>
<swp-module-desc>Forstå dine kunders adfærd og forbliv proaktiv. AI'en analyserer bookingmønstre, forudser hvornår kunder har brug for en tid, og identificerer kunder der er ved at falde fra.</swp-module-desc>
</swp-module-info>
<swp-module-toggle>
<swp-toggle-slider data-value="no">
<swp-toggle-option>Til</swp-toggle-option>
<swp-toggle-option>Fra</swp-toggle-option>
</swp-toggle-slider>
</swp-module-toggle>
</swp-module-header>
<swp-module-features>
<swp-module-feature class="purple">
<i class="ph ph-check-circle"></i>
<span>Booking-prediktion baseret på historik</span>
</swp-module-feature>
<swp-module-feature class="purple">
<i class="ph ph-check-circle"></i>
<span>Churn-detektion se hvem der er ved at falde fra</span>
</swp-module-feature>
<swp-module-feature class="purple">
<i class="ph ph-check-circle"></i>
<span>Service-præference analyse pr. kunde</span>
</swp-module-feature>
<swp-module-feature class="purple">
<i class="ph ph-check-circle"></i>
<span>Sæson-korrektion i forudsigelser</span>
</swp-module-feature>
<swp-module-feature class="purple">
<i class="ph ph-check-circle"></i>
<span>Win-back kampagner for inaktive kunder</span>
</swp-module-feature>
<swp-module-feature class="purple">
<i class="ph ph-check-circle"></i>
<span>Automatisk personlig beskedgenerering</span>
</swp-module-feature>
</swp-module-features>
<swp-module-stats class="purple">
<swp-module-stat>
<swp-module-stat-value>-34%</swp-module-stat-value>
<swp-module-stat-label>Færre tabte kunder</swp-module-stat-label>
</swp-module-stat>
<swp-module-stat>
<swp-module-stat-value>+18%</swp-module-stat-value>
<swp-module-stat-label>Genbookinger</swp-module-stat-label>
</swp-module-stat>
<swp-module-stat>
<swp-module-stat-value>3.8x</swp-module-stat-value>
<swp-module-stat-label>ROI på kampagner</swp-module-stat-label>
</swp-module-stat>
</swp-module-stats>
<swp-module-footer>
<swp-module-tags>
<swp-module-tag class="price">+79 kr/md</swp-module-tag>
<swp-module-tag class="new">Beta</swp-module-tag>
</swp-module-tags>
<swp-btn class="small primary purple">Prøv gratis i 14 dage</swp-btn>
</swp-module-footer>
</swp-module-card>
<!-- AI Produktsalg --> <!-- AI Produktsalg -->
<swp-module-card class="featured purple"> <swp-module-card class="featured purple">
<swp-module-header> <swp-module-header>