Moving away from Azure Devops #1
60 changed files with 3214 additions and 20 deletions
86
.workbench/projectstructure.txt
Normal file
86
.workbench/projectstructure.txt
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
|
||||||
|
|
||||||
|
Organizing Project Folder Structure: Function-Based vs Feature-Based
|
||||||
|
Ina Lopez
|
||||||
|
Ina Lopez
|
||||||
|
|
||||||
|
Follow
|
||||||
|
2 min read
|
||||||
|
·
|
||||||
|
Sep 3, 2024
|
||||||
|
12
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
When setting up a project, one of the most crucial decisions is how to organize the folder structure. The structure you choose can significantly impact productivity, scalability, and collaboration among developers. Two common approaches are function-based and feature-based organization. Both have their advantages, and the choice between them often depends on the size of the project and the number of developers involved.
|
||||||
|
|
||||||
|
Function-Based Organization
|
||||||
|
|
||||||
|
In a function-based folder structure, directories are organized based on the functions they provide. This is a popular approach, especially for smaller projects or teams. The idea is to group similar functionalities together, making it easy to locate specific files or components.
|
||||||
|
|
||||||
|
For example, in a React project, the src directory might look like this:
|
||||||
|
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
├── hooks/
|
||||||
|
├── utils/
|
||||||
|
|
||||||
|
Each folder contains files related to a specific function. Components, hooks, reducers, and utilities are neatly separated, making it easy to find and manage related code. This structure works well when the codebase is relatively small and developers need to quickly find and reuse functions.
|
||||||
|
|
||||||
|
Pros:
|
||||||
|
|
||||||
|
Easy to find similar functions.
|
||||||
|
Encourages reuse of components and utilities.
|
||||||
|
Clean and straightforward structure.
|
||||||
|
Cons:
|
||||||
|
|
||||||
|
As the project grows, it can become difficult to manage.
|
||||||
|
Dependencies between different folders can increase complexity.
|
||||||
|
Not ideal for teams working on different features simultaneously.
|
||||||
|
Feature-Based Organization
|
||||||
|
In larger projects with many developers, a feature-based folder structure can be more effective. Instead of organizing files by function, the top-level directories in the src folder are based on features or modules of the application. This approach allows teams to work on separate features independently without interfering with other parts of the codebase.
|
||||||
|
|
||||||
|
Get Ina Lopez’s stories in your inbox
|
||||||
|
Join Medium for free to get updates from this writer.
|
||||||
|
|
||||||
|
Enter your email
|
||||||
|
Subscribe
|
||||||
|
For example, a feature-based structure might look like this:
|
||||||
|
|
||||||
|
src/
|
||||||
|
├─ signup/
|
||||||
|
│ ├── components/
|
||||||
|
│ ├── hooks/
|
||||||
|
│ └── utils/
|
||||||
|
├─ checkout/
|
||||||
|
│ ├── components/
|
||||||
|
│ ├── hooks/
|
||||||
|
│ └── utils/
|
||||||
|
├─ dashboard/
|
||||||
|
│ ├── components/
|
||||||
|
│ ├── hooks/
|
||||||
|
│ └── utils/
|
||||||
|
└─ profile/
|
||||||
|
├── components/
|
||||||
|
├── hooks/
|
||||||
|
└── utils/
|
||||||
|
|
||||||
|
Each folder contains all the components, hooks, reducers, and utilities specific to that feature. This structure makes it easier for developers to focus on specific features, reduces conflicts, and simplifies the onboarding process for new team members.
|
||||||
|
|
||||||
|
Pros:
|
||||||
|
|
||||||
|
Better suited for larger projects with multiple developers.
|
||||||
|
Encourages modularity and separation of concerns.
|
||||||
|
Easier to manage and scale as the project grows.
|
||||||
|
Reduces the risk of conflicts between different teams.
|
||||||
|
Cons:
|
||||||
|
|
||||||
|
Some duplication of code across features is possible.
|
||||||
|
Finding reusable components can be more challenging.
|
||||||
|
Can be overwhelming if the project has too many small features.
|
||||||
|
Conclusion
|
||||||
|
Choosing the right folder structure depends on your project’s size and team dynamics. Function-based organization is ideal for small to medium projects with fewer developers, offering simplicity and ease of reuse. However, as your project grows and more developers are involved, a feature-based approach becomes more effective, enabling modularity, better collaboration, and easier scaling.
|
||||||
|
|
||||||
|
For some projects, a hybrid approach might work best, combining both methods to balance flexibility and organization. Ultimately, the key is to select a structure that supports the current and future needs of your project and team.
|
||||||
|
|
||||||
20
Program.cs
20
Program.cs
|
|
@ -1,20 +0,0 @@
|
||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.FileProviders;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
// Configure static files to serve from current directory
|
|
||||||
app.UseStaticFiles(new StaticFileOptions
|
|
||||||
{
|
|
||||||
FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot")),
|
|
||||||
RequestPath = ""
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fallback to index.html for SPA routing
|
|
||||||
app.MapFallbackToFile("index.html");
|
|
||||||
|
|
||||||
app.Run("http://localhost:8000");
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>CalendarServer</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
12
app/Controllers/HomeController.cs
Normal file
12
app/Controllers/HomeController.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace CalendarServer.Controllers;
|
||||||
|
|
||||||
|
public class HomeController : Controller
|
||||||
|
{
|
||||||
|
public IActionResult Index()
|
||||||
|
{
|
||||||
|
ViewData["Title"] = "Dashboard";
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/Features/Language/Models/SupportedCulture.cs
Normal file
8
app/Features/Language/Models/SupportedCulture.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
10
app/Features/Language/Services/ILocalizationService.cs
Normal file
10
app/Features/Language/Services/ILocalizationService.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
48
app/Features/Language/Services/JsonLocalizationService.cs
Normal file
48
app/Features/Language/Services/JsonLocalizationService.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
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" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Features/Language/TagHelpers/LocalizeTagHelper.cs
Normal file
30
app/Features/Language/TagHelpers/LocalizeTagHelper.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Features/Language/Translations/da.json
Normal file
33
app/Features/Language/Translations/da.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Features/Language/Translations/en.json
Normal file
33
app/Features/Language/Translations/en.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/Features/Menu/Models/MenuGroup.cs
Normal file
12
app/Features/Menu/Models/MenuGroup.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
15
app/Features/Menu/Models/MenuItem.cs
Normal file
15
app/Features/Menu/Models/MenuItem.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
11
app/Features/Menu/Models/UserRole.cs
Normal file
11
app/Features/Menu/Models/UserRole.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
14
app/Features/Menu/Services/IMenuService.cs
Normal file
14
app/Features/Menu/Services/IMenuService.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
187
app/Features/Menu/Services/MockMenuService.cs
Normal file
187
app/Features/Menu/Services/MockMenuService.cs
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Features/Menu/SideMenuViewComponent.cs
Normal file
35
app/Features/Menu/SideMenuViewComponent.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/Features/Menu/SideMenuViewModel.cs
Normal file
12
app/Features/Menu/SideMenuViewModel.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
29
app/Program.cs
Normal file
29
app/Program.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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");
|
||||||
91
app/Views/Home/Index.cshtml
Normal file
91
app/Views/Home/Index.cshtml
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
@{
|
||||||
|
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>
|
||||||
39
app/Views/Shared/Components/SideMenu/Default.cshtml
Normal file
39
app/Views/Shared/Components/SideMenu/Default.cshtml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
@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>
|
||||||
49
app/Views/Shared/Components/_ProfileDrawer.cshtml
Normal file
49
app/Views/Shared/Components/_ProfileDrawer.cshtml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<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>
|
||||||
78
app/Views/Shared/Components/_SideMenu.cshtml
Normal file
78
app/Views/Shared/Components/_SideMenu.cshtml
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<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>
|
||||||
26
app/Views/Shared/Components/_TopBar.cshtml
Normal file
26
app/Views/Shared/Components/_TopBar.cshtml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<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>
|
||||||
38
app/Views/Shared/_Layout.cshtml
Normal file
38
app/Views/Shared/_Layout.cshtml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<!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>
|
||||||
3
app/Views/_ViewImports.cshtml
Normal file
3
app/Views/_ViewImports.cshtml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@using CalendarServer
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@addTagHelper *, CalendarServer
|
||||||
3
app/Views/_ViewStart.cshtml
Normal file
3
app/Views/_ViewStart.cshtml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
24
app/build.js
Normal file
24
app/build.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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();
|
||||||
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