From fb1cb8e83421e24b6f9fc723bbf90529e9d2557e Mon Sep 17 00:00:00 2001 From: shaamilahmed Date: Mon, 4 May 2026 16:53:19 +0500 Subject: [PATCH] Added components, authentication and authorization --- .gitignore | 4 +- Htmx.ApiDemo/AppJsonSerializerContext.cs | 6 +- Htmx.ApiDemo/Data/ApplicationUser.cs | 31 + Htmx.ApiDemo/Data/AuthService.cs | 83 + Htmx.ApiDemo/Data/MongoDbService.cs | 47 + Htmx.ApiDemo/Htmx.ApiDemo.csproj | 25 +- Htmx.ApiDemo/HtmxPageExtensions.cs | 44 + Htmx.ApiDemo/Program.cs | 78 + Htmx.ApiDemo/Templates/Components/Button.htmx | 1 + .../Templates/Components/Button.htmx.cs | 59 + .../Templates/Components/Calendar.htmx | 38 + .../Templates/Components/Calendar.htmx.cs | 43 + .../Templates/Components/CalendarRange.htmx | 41 + .../Components/CalendarRange.htmx.cs | 49 + Htmx.ApiDemo/Templates/Components/Input.htmx | 15 + .../Templates/Components/Input.htmx.cs | 52 + Htmx.ApiDemo/Templates/Components/Select.htmx | 14 + .../Templates/Components/Select.htmx.cs | 56 + .../Templates/Components/TimePicker.htmx | 28 + .../Templates/Components/TimePicker.htmx.cs | 89 ++ Htmx.ApiDemo/Templates/Greeting.htmx | 4 +- Htmx.ApiDemo/Templates/Greeting.htmx.cs | 28 +- Htmx.ApiDemo/Templates/Login.htmx | 45 + Htmx.ApiDemo/Templates/Login.htmx.cs | 88 ++ Htmx.ApiDemo/Templates/Logout.cs | 28 + Htmx.ApiDemo/Templates/MainLayout.htmx | 116 ++ Htmx.ApiDemo/Templates/MainLayout.htmx.cs | 91 ++ Htmx.ApiDemo/Templates/Register.htmx | 63 + Htmx.ApiDemo/Templates/Register.htmx.cs | 99 ++ Htmx.ApiDemo/Templates/UiDemo.htmx | 68 + Htmx.ApiDemo/Templates/UiDemo.htmx.cs | 101 ++ Htmx.ApiDemo/appsettings.json | 4 + Htmx.ApiDemo/package.json | 6 + Htmx.ApiDemo/wwwroot/css/input.css | 187 +++ Htmx.ApiDemo/wwwroot/css/output.css | 1365 +++++++++++++++++ Htmx.ApiDemo/wwwroot/js/components.js | 458 ++++++ Htmx.SourceGenerator/HtmxGenerator.cs | 12 +- 37 files changed, 3545 insertions(+), 21 deletions(-) create mode 100644 Htmx.ApiDemo/Data/ApplicationUser.cs create mode 100644 Htmx.ApiDemo/Data/AuthService.cs create mode 100644 Htmx.ApiDemo/Data/MongoDbService.cs create mode 100644 Htmx.ApiDemo/HtmxPageExtensions.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Button.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Button.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Calendar.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Calendar.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/CalendarRange.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/CalendarRange.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Input.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Input.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/Select.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/Select.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Components/TimePicker.htmx create mode 100644 Htmx.ApiDemo/Templates/Components/TimePicker.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Login.htmx create mode 100644 Htmx.ApiDemo/Templates/Login.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Logout.cs create mode 100644 Htmx.ApiDemo/Templates/MainLayout.htmx create mode 100644 Htmx.ApiDemo/Templates/MainLayout.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/Register.htmx create mode 100644 Htmx.ApiDemo/Templates/Register.htmx.cs create mode 100644 Htmx.ApiDemo/Templates/UiDemo.htmx create mode 100644 Htmx.ApiDemo/Templates/UiDemo.htmx.cs create mode 100644 Htmx.ApiDemo/package.json create mode 100644 Htmx.ApiDemo/wwwroot/css/input.css create mode 100644 Htmx.ApiDemo/wwwroot/css/output.css create mode 100644 Htmx.ApiDemo/wwwroot/js/components.js diff --git a/.gitignore b/.gitignore index 8d4a6c0..b759220 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ bin -obj \ No newline at end of file +obj +node_modules +package-lock.json \ No newline at end of file diff --git a/Htmx.ApiDemo/AppJsonSerializerContext.cs b/Htmx.ApiDemo/AppJsonSerializerContext.cs index d3c188b..840b621 100644 --- a/Htmx.ApiDemo/AppJsonSerializerContext.cs +++ b/Htmx.ApiDemo/AppJsonSerializerContext.cs @@ -1,9 +1,13 @@ using System.Text.Json.Serialization; +using Htmx.ApiDemo.Templates; namespace Htmx.ApiDemo; [JsonSerializable(typeof(string))] +[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")] +[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")] +[JsonSerializable(typeof(PostLogoutHandler.Command), TypeInfoPropertyName = "LogoutCommand")] internal partial class AppJsonSerializerContext : JsonSerializerContext { - + } \ No newline at end of file diff --git a/Htmx.ApiDemo/Data/ApplicationUser.cs b/Htmx.ApiDemo/Data/ApplicationUser.cs new file mode 100644 index 0000000..1b0fe91 --- /dev/null +++ b/Htmx.ApiDemo/Data/ApplicationUser.cs @@ -0,0 +1,31 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Htmx.ApiDemo.Data; + +/// +/// Simple user document stored in MongoDB. +/// All property→field name mappings are declared explicitly via [BsonElement] +/// and registered in Program.cs via BsonClassMap — no AutoMap() reflection. +/// +public sealed class AppUser +{ + [BsonId] + public ObjectId Id { get; set; } = ObjectId.GenerateNewId(); + + [BsonElement("email")] + public string Email { get; set; } = ""; + + /// Email.ToUpperInvariant() — used for case-insensitive lookups. + [BsonElement("normalizedEmail")] + public string NormalizedEmail { get; set; } = ""; + + [BsonElement("passwordHash")] + public string PasswordHash { get; set; } = ""; + + [BsonElement("displayName")] + public string? DisplayName { get; set; } + + [BsonElement("createdAtUtc")] + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/Htmx.ApiDemo/Data/AuthService.cs b/Htmx.ApiDemo/Data/AuthService.cs new file mode 100644 index 0000000..1a1aeaa --- /dev/null +++ b/Htmx.ApiDemo/Data/AuthService.cs @@ -0,0 +1,83 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; + +namespace Htmx.ApiDemo.Data; + +/// +/// AOT-safe authentication service backed by MongoDB. +/// No EF Core, no LINQ-to-SQL, no RelationalModel fully NativeAOT safe. +/// IPasswordHasher is pure PBKDF2 crypto with no dynamic IL. +/// +public sealed class AuthService( + MongoDbService mongo, + IPasswordHasher passwordHasher, + IHttpContextAccessor httpContextAccessor) +{ + public async Task<(bool Success, string? Error)> RegisterAsync( + string email, string password, string? displayName, CancellationToken ct = default) + { + var normalized = email.ToUpperInvariant(); + + if (await mongo.EmailExistsAsync(normalized, ct)) + return (false, "That email address is already registered."); + + var user = new AppUser + { + Email = email, + NormalizedEmail = normalized, + DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(), + CreatedAtUtc = DateTime.UtcNow, + }; + user.PasswordHash = passwordHasher.HashPassword(user, password); + + await mongo.InsertAsync(user, ct); + await SignInUserAsync(user); + return (true, null); + } + + public async Task<(bool Success, string? Error)> LoginAsync( + string email, string password, CancellationToken ct = default) + { + var normalized = email.ToUpperInvariant(); + var user = await mongo.FindByNormalizedEmailAsync(normalized, ct); + + if (user is null) + return (false, "Invalid email or password."); + + var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); + if (result == PasswordVerificationResult.Failed) + return (false, "Invalid email or password."); + + await SignInUserAsync(user); + return (true, null); + } + + public async Task SignOutAsync() + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + } + + private async Task SignInUserAsync(AppUser user) + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + List claims = + [ + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Email), + new Claim(ClaimTypes.Email, user.Email), + ]; + + if (!string.IsNullOrEmpty(user.DisplayName)) + claims.Add(new Claim("DisplayName", user.DisplayName)); + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); + } +} diff --git a/Htmx.ApiDemo/Data/MongoDbService.cs b/Htmx.ApiDemo/Data/MongoDbService.cs new file mode 100644 index 0000000..0cf49fa --- /dev/null +++ b/Htmx.ApiDemo/Data/MongoDbService.cs @@ -0,0 +1,47 @@ +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Htmx.ApiDemo.Data; + +/// +/// Scoped service wrapping the AppUser MongoDB collection. +/// All operations use MongoDB's native async API — no EF, no LINQ-to-SQL +/// translation, no RelationalModel, fully NativeAOT safe. +/// +public sealed class MongoDbService +{ + private readonly IMongoCollection _users; + + public MongoDbService(IMongoClient client, IConfiguration configuration) + { + var db = client.GetDatabase(configuration["MongoDbName"] ?? "HtmxAppDb"); + _users = db.GetCollection("users"); + } + + /// Ensures the unique index on NormalizedEmail exists (idempotent). + public async Task EnsureIndexesAsync(CancellationToken ct = default) + { + var indexKeys = Builders.IndexKeys.Ascending(u => u.NormalizedEmail); + var indexOptions = new CreateIndexOptions { Unique = true, Name = "uq_normalizedEmail" }; + var model = new CreateIndexModel(indexKeys, indexOptions); + await _users.Indexes.CreateOneAsync(model, cancellationToken: ct); + } + + /// Returns true if a user with the given normalised email already exists. + public async Task EmailExistsAsync(string normalizedEmail, CancellationToken ct = default) + { + var filter = Builders.Filter.Eq(u => u.NormalizedEmail, normalizedEmail); + return await _users.Find(filter).AnyAsync(ct); + } + + /// Returns the user matching the normalised email, or null. + public async Task FindByNormalizedEmailAsync(string normalizedEmail, CancellationToken ct = default) + { + var filter = Builders.Filter.Eq(u => u.NormalizedEmail, normalizedEmail); + return await _users.Find(filter).FirstOrDefaultAsync(ct); + } + + /// Inserts a new user document. + public Task InsertAsync(AppUser user, CancellationToken ct = default) => + _users.InsertOneAsync(user, cancellationToken: ct); +} diff --git a/Htmx.ApiDemo/Htmx.ApiDemo.csproj b/Htmx.ApiDemo/Htmx.ApiDemo.csproj index fda4646..b80b84b 100644 --- a/Htmx.ApiDemo/Htmx.ApiDemo.csproj +++ b/Htmx.ApiDemo/Htmx.ApiDemo.csproj @@ -4,30 +4,43 @@ net10.0 enable enable - true true true obj/Generated + + true + true + $(InterceptorsPreviewNamespaces);Immediate.Apis.Generators + + + + + - + + - - - - + + + + --minify + + + + diff --git a/Htmx.ApiDemo/HtmxPageExtensions.cs b/Htmx.ApiDemo/HtmxPageExtensions.cs new file mode 100644 index 0000000..eddb8e0 --- /dev/null +++ b/Htmx.ApiDemo/HtmxPageExtensions.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Antiforgery; + +namespace Htmx.ApiDemo; + +/// +/// Renders a full page or just the body component depending on whether +/// the request was made by HTMX (HX-Request header present). +/// +/// Full request → wraps body in MainLayout (complete HTML page) +/// HTMX request → renders body only + sets HX-Title so the browser +/// tab title still updates +/// +public static class HtmxPageExtensions +{ + public static void WriteHtmxPage( + this HttpContext ctx, + IHtmxComponent body, + string title = "App", + string appName = "HtmxApp", + string pageTitle = "") + { + if (ctx.Request.Headers.ContainsKey("HX-Request")) + { + // Partial swap: tell HTMX to update the browser tag + ctx.Response.Headers["HX-Title"] = title; + ctx.WriteHtmxBody(body); + } + else + { + // Resolve display name: prefer DisplayName claim, fall back to email/name + string? userName = ctx.User.Identity?.IsAuthenticated == true + ? (ctx.User.FindFirst("DisplayName")?.Value + ?? ctx.User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value) + : null; + + // Resolve antiforgery token for the logout form in the layout + var antiforgery = ctx.RequestServices.GetRequiredService<IAntiforgery>(); + var afTokens = antiforgery.GetAndStoreTokens(ctx); + + // Full page load: wrap in the shell layout + ctx.WriteHtmxBody(new Templates.MainLayout(body, title, appName, pageTitle, userName, afTokens.RequestToken)); + } + } +} diff --git a/Htmx.ApiDemo/Program.cs b/Htmx.ApiDemo/Program.cs index 33b7644..124377c 100644 --- a/Htmx.ApiDemo/Program.cs +++ b/Htmx.ApiDemo/Program.cs @@ -1,22 +1,100 @@ using Htmx.ApiDemo; +using Htmx.ApiDemo.Data; +using Immediate.Apis; +using Immediate.Apis.Shared; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using MongoDB.Driver; + +// ── Explicit BsonClassMap — no AutoMap() reflection, fully AOT-safe ─────── +BsonClassMap.RegisterClassMap<AppUser>(cm => +{ + cm.MapIdProperty(u => u.Id).SetSerializer(new ObjectIdSerializer()); + cm.MapProperty(u => u.Email).SetElementName("email"); + cm.MapProperty(u => u.NormalizedEmail).SetElementName("normalizedEmail"); + cm.MapProperty(u => u.PasswordHash).SetElementName("passwordHash"); + cm.MapProperty(u => u.DisplayName).SetElementName("displayName"); + cm.MapProperty(u => u.CreatedAtUtc).SetElementName("createdAtUtc"); + cm.SetIgnoreExtraElements(true); +}); var builder = WebApplication.CreateSlimBuilder(args); + +// ── Antiforgery ─────────────────────────────────────────────────────────── + +builder.Services.AddAntiforgery(); + +// ── JSON ────────────────────────────────────────────────────────────────── builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); }); + +// ── MongoDB ─────────────────────────────────────────────────────────────── +builder.Services.AddSingleton<IMongoClient>( + new MongoClient(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped<MongoDbService>(); + +// ── Cookie Authentication ───────────────────────────────────────────────── +builder.Services + .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/login"; + options.LogoutPath = "/logout"; + options.AccessDeniedPath = "/login"; + options.SlidingExpiration = true; + options.ExpireTimeSpan = TimeSpan.FromHours(8); + }); + +builder.Services.AddScoped<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>(); +builder.Services.AddScoped<AuthService>(); + +// ── App services ────────────────────────────────────────────────────────── builder.Services.AddHttpContextAccessor(); builder.Services .AddHtmxApiDemoBehaviors() .AddHtmxApiDemoHandlers(); builder.Services.AddOpenApi(); +builder.Services.AddAuthorization(); var app = builder.Build(); +// Ensure the unique index on NormalizedEmail exists (runs once on startup, idempotent). +using (var scope = app.Services.CreateScope()) + await scope.ServiceProvider.GetRequiredService<MongoDbService>().EnsureIndexesAsync(); + if (app.Environment.IsDevelopment()) app.MapOpenApi(); +app.UseStaticFiles(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseAntiforgery(); + +// ── Guard: redirect unauthenticated users to /login ─────────────────────── +app.Use(async (context, next) => +{ + var path = context.Request.Path.Value ?? ""; + bool isPublic = path.StartsWith("/login", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/register", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/logout", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/css/", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/js/", StringComparison.OrdinalIgnoreCase); + + if (!isPublic && context.User.Identity?.IsAuthenticated != true) + { + context.Response.Redirect("/login"); + return; + } + + await next(); +}); + app.MapHtmxApiDemoEndpoints(); app.Run(); \ No newline at end of file diff --git a/Htmx.ApiDemo/Templates/Components/Button.htmx b/Htmx.ApiDemo/Templates/Components/Button.htmx new file mode 100644 index 0000000..f324aec --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Button.htmx @@ -0,0 +1 @@ +<button type="$$Type$$" class="$$Classes$$" $$HxAttrs$$>$$Label$$</button> diff --git a/Htmx.ApiDemo/Templates/Components/Button.htmx.cs b/Htmx.ApiDemo/Templates/Components/Button.htmx.cs new file mode 100644 index 0000000..9b96d4b --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Button.htmx.cs @@ -0,0 +1,59 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// <summary> +/// shadcn-style Button component. +/// Variant: default | destructive | outline | secondary | ghost | link +/// Size: default | sm | lg | icon +/// </summary> +public sealed class Button : ButtonBase +{ + private static readonly Dictionary<string, string> VariantClasses = new() + { + ["default"] = "bg-primary text-primary-foreground hover:bg-primary/90", + ["destructive"] = "bg-destructive text-destructive-foreground hover:bg-destructive/90", + ["outline"] = "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + ["secondary"] = "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ["ghost"] = "hover:bg-accent hover:text-accent-foreground", + ["link"] = "text-primary underline-offset-4 hover:underline", + }; + + private static readonly Dictionary<string, string> SizeClasses = new() + { + ["default"] = "h-10 px-4 py-2 text-sm", + ["sm"] = "h-9 rounded-md px-3 text-xs", + ["lg"] = "h-11 rounded-md px-8 text-base", + ["icon"] = "h-10 w-10", + }; + + private const string BaseClasses = + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium " + + "ring-offset-background transition-colors " + + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 " + + "disabled:pointer-events-none disabled:opacity-50"; + + private readonly byte[] _labelData; + private readonly byte[] _classesData; + private readonly byte[] _typeData; + private readonly byte[] _hxAttrsData; + + public Button( + string label, + string variant = "default", + string size = "default", + string type = "button", + string hxAttrs = "") + { + _labelData = label.ToUtf8Bytes(); + _typeData = type.ToUtf8Bytes(); + _hxAttrsData = hxAttrs.ToUtf8Bytes(); + + var v = VariantClasses.GetValueOrDefault(variant, VariantClasses["default"]); + var s = SizeClasses.GetValueOrDefault(size, SizeClasses["default"]); + _classesData = $"{BaseClasses} {s} {v}".ToUtf8Bytes(); + } + + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); + protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData); + protected override void RenderType(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_typeData); + protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Calendar.htmx b/Htmx.ApiDemo/Templates/Components/Calendar.htmx new file mode 100644 index 0000000..7607683 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Calendar.htmx @@ -0,0 +1,38 @@ +<div id="cal-$$Id$$" + class="calendar-root inline-block min-w-72 rounded-md border border-border bg-card p-4 shadow-sm" + data-year="$$Year$$" + data-month="$$Month$$" + data-sel-day="$$SelectedDay$$" + data-sel-month="$$SelectedMonth$$" + data-sel-year="$$SelectedYear$$" + data-view="days"> + + <!-- Header row --> + <div class="mb-3 flex items-center justify-between"> + <button type="button" class="cal-prev cal-nav inline-flex h-8 w-8 items-center justify-center rounded-md border border-input + bg-transparent hover:bg-accent hover:text-accent-foreground transition-colors text-base" + aria-label="Previous month">‹</button> + <button type="button" class="cal-month-label text-sm font-semibold px-2 py-0.5 rounded-md + hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"></button> + <button type="button" class="cal-next cal-nav inline-flex h-8 w-8 items-center justify-center rounded-md border border-input + bg-transparent hover:bg-accent hover:text-accent-foreground transition-colors text-base" + aria-label="Next month">›</button> + </div> + + <!-- Day-of-week headers --> + <div class="cal-dow-row mb-1 grid grid-cols-7 text-center"> + <span class="cal-dow">Su</span> + <span class="cal-dow">Mo</span> + <span class="cal-dow">Tu</span> + <span class="cal-dow">We</span> + <span class="cal-dow">Th</span> + <span class="cal-dow">Fr</span> + <span class="cal-dow">Sa</span> + </div> + + <!-- Day grid (populated by JS below) --> + <div class="cal-grid grid grid-cols-7 gap-0.5 text-center"></div> + + <!-- Hidden input --> + <input type="hidden" name="$$Name$$" class="cal-hidden-input" value="$$DefaultValue$$" /> +</div> diff --git a/Htmx.ApiDemo/Templates/Components/Calendar.htmx.cs b/Htmx.ApiDemo/Templates/Components/Calendar.htmx.cs new file mode 100644 index 0000000..53f3d77 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Calendar.htmx.cs @@ -0,0 +1,43 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// <summary> +/// shadcn-style Calendar (date-picker) component driven entirely by HyperScript. +/// Pass a selected date to pre-highlight a day; defaults to today. +/// </summary> +public sealed class Calendar : CalendarBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameData; + private readonly byte[] _yearData; + private readonly byte[] _monthData; // 0-based JS month + private readonly byte[] _selectedDayData; + private readonly byte[] _selectedMonthData; // 0-based + private readonly byte[] _selectedYearData; + private readonly byte[] _defaultValueData; + + public Calendar( + string id, + string name = "date", + DateOnly? selected = null) + { + var date = selected ?? DateOnly.FromDateTime(DateTime.Today); + + _idData = id.ToUtf8Bytes(); + _nameData = name.ToUtf8Bytes(); + _yearData = date.Year.ToString().ToUtf8Bytes(); + _monthData = (date.Month - 1).ToString().ToUtf8Bytes(); // JS months are 0-based + _selectedDayData = date.Day.ToString().ToUtf8Bytes(); + _selectedMonthData= (date.Month - 1).ToString().ToUtf8Bytes(); + _selectedYearData = date.Year.ToString().ToUtf8Bytes(); + _defaultValueData = date.ToString("yyyy-MM-dd").ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderYear(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_yearData); + protected override void RenderMonth(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_monthData); + protected override void RenderSelectedDay(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_selectedDayData); + protected override void RenderSelectedMonth(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_selectedMonthData); + protected override void RenderSelectedYear(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_selectedYearData); + protected override void RenderDefaultValue(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultValueData); +} diff --git a/Htmx.ApiDemo/Templates/Components/CalendarRange.htmx b/Htmx.ApiDemo/Templates/Components/CalendarRange.htmx new file mode 100644 index 0000000..657cd8c --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/CalendarRange.htmx @@ -0,0 +1,41 @@ +<div id="calr-$$Id$$" + class="calr-root inline-block min-w-72 rounded-md border border-border bg-card p-4 shadow-sm" + data-year="$$Year$$" + data-month="$$Month$$" + data-start="$$DefaultStart$$" + data-end="$$DefaultEnd$$" + data-view="days"> + + <!-- Header row --> + <div class="mb-3 flex items-center justify-between"> + <button type="button" class="calr-prev cal-nav inline-flex h-8 w-8 items-center justify-center rounded-md border border-input + bg-transparent hover:bg-accent hover:text-accent-foreground transition-colors text-base" + aria-label="Previous month">‹</button> + <button type="button" class="calr-month-label text-sm font-semibold px-2 py-0.5 rounded-md + hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"></button> + <button type="button" class="calr-next cal-nav inline-flex h-8 w-8 items-center justify-center rounded-md border border-input + bg-transparent hover:bg-accent hover:text-accent-foreground transition-colors text-base" + aria-label="Next month">›</button> + </div> + + <!-- Day-of-week headers --> + <div class="cal-dow-row mb-1 grid grid-cols-7 text-center"> + <span class="cal-dow">Su</span> + <span class="cal-dow">Mo</span> + <span class="cal-dow">Tu</span> + <span class="cal-dow">We</span> + <span class="cal-dow">Th</span> + <span class="cal-dow">Fr</span> + <span class="cal-dow">Sa</span> + </div> + + <!-- Day grid (populated by JS) --> + <div class="calr-grid grid grid-cols-7 text-center"></div> + + <!-- Range label --> + <div class="calr-label mt-3 text-xs text-muted-foreground min-h-4"></div> + + <!-- Hidden inputs --> + <input type="hidden" name="$$NameStart$$" class="calr-hidden-start" value="$$DefaultStart$$" /> + <input type="hidden" name="$$NameEnd$$" class="calr-hidden-end" value="$$DefaultEnd$$" /> +</div> diff --git a/Htmx.ApiDemo/Templates/Components/CalendarRange.htmx.cs b/Htmx.ApiDemo/Templates/Components/CalendarRange.htmx.cs new file mode 100644 index 0000000..4a844c6 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/CalendarRange.htmx.cs @@ -0,0 +1,49 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// <summary> +/// shadcn-style range Calendar. Lets the user pick a start and end date. +/// State and rendering are handled by components.js (initCalendarRange). +/// Fires a <c>rangeChange</c> CustomEvent with <c>{ start, end }</c> detail. +/// </summary> +public sealed class CalendarRange : CalendarRangeBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameStartData; + private readonly byte[] _nameEndData; + private readonly byte[] _yearData; + private readonly byte[] _monthData; + private readonly byte[] _defaultStartData; + private readonly byte[] _defaultEndData; + + public CalendarRange( + string id, + string name = "date", + DateOnly? selectedStart = null, + DateOnly? selectedEnd = null) + { + // Show the start month if provided, otherwise today + var viewDate = selectedStart ?? DateOnly.FromDateTime(DateTime.Today); + + _idData = id.ToUtf8Bytes(); + _nameStartData = (name + "-start").ToUtf8Bytes(); + _nameEndData = (name + "-end").ToUtf8Bytes(); + _yearData = viewDate.Year.ToString().ToUtf8Bytes(); + _monthData = (viewDate.Month - 1).ToString().ToUtf8Bytes(); // 0-based + + _defaultStartData = selectedStart.HasValue + ? selectedStart.Value.ToString("yyyy-MM-dd").ToUtf8Bytes() + : [] ; + + _defaultEndData = selectedEnd.HasValue + ? selectedEnd.Value.ToString("yyyy-MM-dd").ToUtf8Bytes() + : []; + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderNameStart(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameStartData); + protected override void RenderNameEnd(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameEndData); + protected override void RenderYear(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_yearData); + protected override void RenderMonth(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_monthData); + protected override void RenderDefaultStart(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultStartData); + protected override void RenderDefaultEnd(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultEndData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Input.htmx b/Htmx.ApiDemo/Templates/Components/Input.htmx new file mode 100644 index 0000000..e885de6 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Input.htmx @@ -0,0 +1,15 @@ +<div class="flex flex-col gap-1.5"> + $$Label$$ + <input + id="$$Id$$" + name="$$Name$$" + type="$$InputType$$" + placeholder="$$Placeholder$$" + class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm + ring-offset-background placeholder:text-muted-foreground + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 + disabled:cursor-not-allowed disabled:opacity-50 $$ExtraClasses$$" + $$HxAttrs$$ + /> + $$Description$$ +</div> diff --git a/Htmx.ApiDemo/Templates/Components/Input.htmx.cs b/Htmx.ApiDemo/Templates/Components/Input.htmx.cs new file mode 100644 index 0000000..b94bec0 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Input.htmx.cs @@ -0,0 +1,52 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// <summary> +/// shadcn-style Input component with optional label and description. +/// InputType: text | email | password | number | search | tel | url | date | time +/// </summary> +public sealed class Input : InputBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameData; + private readonly byte[] _inputTypeData; + private readonly byte[] _placeholderData; + private readonly byte[] _labelData; + private readonly byte[] _descriptionData; + private readonly byte[] _extraClassesData; + private readonly byte[] _hxAttrsData; + + public Input( + string id, + string name = "", + string inputType = "text", + string placeholder = "", + string label = "", + string description = "", + string extraClasses = "", + string hxAttrs = "") + { + _idData = id.ToUtf8Bytes(); + _nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes(); + _inputTypeData = inputType.ToUtf8Bytes(); + _placeholderData = placeholder.ToUtf8Bytes(); + _extraClassesData = extraClasses.ToUtf8Bytes(); + _hxAttrsData = hxAttrs.ToUtf8Bytes(); + + _labelData = string.IsNullOrEmpty(label) + ? [] + : $"""<label for="{id}" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{label}</label>""".ToUtf8Bytes(); + + _descriptionData = string.IsNullOrEmpty(description) + ? [] + : $"""<p class="text-xs text-muted-foreground">{description}</p>""".ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderInputType(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_inputTypeData); + protected override void RenderPlaceholder(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_placeholderData); + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); + protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData); + protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData); + protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/Select.htmx b/Htmx.ApiDemo/Templates/Components/Select.htmx new file mode 100644 index 0000000..d15e4c6 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Select.htmx @@ -0,0 +1,14 @@ +<div class="flex flex-col gap-1.5"> + $$Label$$ + <select + id="$$Id$$" + name="$$Name$$" + class="flex h-10 w-full items-center justify-between rounded-md border border-input + bg-background px-3 py-2 text-sm ring-offset-background + focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 + disabled:cursor-not-allowed disabled:opacity-50 appearance-none $$ExtraClasses$$" + $$HxAttrs$$> + $$Options$$ + </select> + $$Description$$ +</div> diff --git a/Htmx.ApiDemo/Templates/Components/Select.htmx.cs b/Htmx.ApiDemo/Templates/Components/Select.htmx.cs new file mode 100644 index 0000000..503ed92 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/Select.htmx.cs @@ -0,0 +1,56 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// <summary> +/// shadcn-style Select (native HTML select) component. +/// </summary> +public sealed class Select : SelectBase +{ + private readonly byte[] _idData; + private readonly byte[] _nameData; + private readonly byte[] _labelData; + private readonly byte[] _descriptionData; + private readonly byte[] _optionsData; + private readonly byte[] _extraClassesData; + private readonly byte[] _hxAttrsData; + + /// <param name="options">Collection of (value, display) tuples. Mark selected with selectedValue.</param> + public Select( + string id, + IEnumerable<(string Value, string Display)> options, + string selectedValue = "", + string name = "", + string label = "", + string description = "", + string extraClasses = "", + string hxAttrs = "") + { + _idData = id.ToUtf8Bytes(); + _nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes(); + _extraClassesData = extraClasses.ToUtf8Bytes(); + _hxAttrsData = hxAttrs.ToUtf8Bytes(); + + _labelData = string.IsNullOrEmpty(label) + ? [] + : $"""<label for="{id}" class="text-sm font-medium leading-none">{label}</label>""".ToUtf8Bytes(); + + _descriptionData = string.IsNullOrEmpty(description) + ? [] + : $"""<p class="text-xs text-muted-foreground">{description}</p>""".ToUtf8Bytes(); + + var sb = new System.Text.StringBuilder(); + foreach (var (value, display) in options) + { + var selected = value == selectedValue ? " selected" : ""; + sb.Append($"""<option value="{value}"{selected}>{display}</option>"""); + } + _optionsData = sb.ToString().ToUtf8Bytes(); + } + + protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); + protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData); + protected override void RenderOptions(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_optionsData); + protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData); + protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData); +} diff --git a/Htmx.ApiDemo/Templates/Components/TimePicker.htmx b/Htmx.ApiDemo/Templates/Components/TimePicker.htmx new file mode 100644 index 0000000..9f35a90 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/TimePicker.htmx @@ -0,0 +1,28 @@ +<div class="timepicker-root flex flex-col gap-1.5" data-use12h="$$Use12h$$" id="tp-$$UniqueId$$"> + $$Label$$ + <div class="flex items-center gap-1"> + + <!-- Hour --> + <input type="number" min="$$HourMin$$" max="$$HourMax$$" step="1" + class="timepicker-hour h-10 w-16 rounded-md border border-input bg-background px-2 text-center text-sm + ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring + focus-visible:ring-offset-2" + value="$$DefaultHour$$" /> + + <span class="text-sm font-bold text-foreground">:</span> + + <!-- Minute --> + <input type="number" min="0" max="59" step="1" + class="timepicker-minute h-10 w-16 rounded-md border border-input bg-background px-2 text-center text-sm + ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring + focus-visible:ring-offset-2" + value="$$DefaultMinute$$" /> + + <!-- AM/PM toggle (only rendered when use12h=true) --> + $$AmPmToggle$$ + + <!-- Hidden input that stores HH:MM value --> + <input type="hidden" name="$$Name$$" class="timepicker-hidden" value="$$DefaultValue$$" /> + </div> + $$Description$$ +</div> diff --git a/Htmx.ApiDemo/Templates/Components/TimePicker.htmx.cs b/Htmx.ApiDemo/Templates/Components/TimePicker.htmx.cs new file mode 100644 index 0000000..8f8671a --- /dev/null +++ b/Htmx.ApiDemo/Templates/Components/TimePicker.htmx.cs @@ -0,0 +1,89 @@ +namespace Htmx.ApiDemo.Templates.Components; + +/// <summary> +/// shadcn-style TimePicker. Syncs hour+minute inputs to a hidden HH:MM field via inline JS. +/// </summary> +public sealed class TimePicker : TimePickerBase +{ + private readonly byte[] _uniqueIdData; + private readonly byte[] _nameData; + private readonly byte[] _use12hData; + private readonly byte[] _labelData; + private readonly byte[] _descriptionData; + private readonly byte[] _defaultHourData; + private readonly byte[] _defaultMinuteData; + private readonly byte[] _defaultValueData; + private readonly byte[] _hourMinData; + private readonly byte[] _hourMaxData; + private readonly byte[] _amPmToggleData; + + public TimePicker( + string name = "time", + TimeOnly? selected = null, + string label = "", + string description = "", + bool use12h = false) + { + var time = selected ?? TimeOnly.FromDateTime(DateTime.Now); + var uid = Guid.NewGuid().ToString("N")[..8]; // short unique suffix + + _uniqueIdData = uid.ToUtf8Bytes(); + _nameData = name.ToUtf8Bytes(); + _use12hData = (use12h ? "true" : "false").ToUtf8Bytes(); + _defaultValueData = time.ToString("HH:mm").ToUtf8Bytes(); + + _labelData = string.IsNullOrEmpty(label) + ? [] + : $"""<span class="text-sm font-medium leading-none">{label}</span>""".ToUtf8Bytes(); + + _descriptionData = string.IsNullOrEmpty(description) + ? [] + : $"""<p class="text-xs text-muted-foreground">{description}</p>""".ToUtf8Bytes(); + + if (use12h) + { + int hour12 = time.Hour % 12; + if (hour12 == 0) hour12 = 12; + bool isPm = time.Hour >= 12; + + _defaultHourData = hour12.ToString().ToUtf8Bytes(); + _defaultMinuteData = time.Minute.ToString("D2").ToUtf8Bytes(); + _hourMinData = "1".ToUtf8Bytes(); + _hourMaxData = "12".ToUtf8Bytes(); + _amPmToggleData = BuildAmPmToggle(isPm); + } + else + { + _defaultHourData = time.Hour.ToString("D2").ToUtf8Bytes(); + _defaultMinuteData = time.Minute.ToString("D2").ToUtf8Bytes(); + _hourMinData = "0".ToUtf8Bytes(); + _hourMaxData = "23".ToUtf8Bytes(); + _amPmToggleData = []; + } + } + + private static byte[] BuildAmPmToggle(bool isPm) + { + var amSel = isPm ? "" : " selected"; + var pmSel = isPm ? " selected" : ""; + return $""" + <select class="timepicker-ampm h-10 rounded-md border border-input bg-background px-2 text-sm + focus:outline-none focus:ring-2 focus:ring-ring"> + <option value="AM"{amSel}>AM</option> + <option value="PM"{pmSel}>PM</option> + </select> + """.ToUtf8Bytes(); + } + + protected override void RenderUniqueId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_uniqueIdData); + protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData); + protected override void RenderUse12h(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_use12hData); + protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData); + protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData); + protected override void RenderDefaultHour(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultHourData); + protected override void RenderDefaultMinute(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultMinuteData); + protected override void RenderDefaultValue(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultValueData); + protected override void RenderHourMin(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hourMinData); + protected override void RenderHourMax(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hourMaxData); + protected override void RenderAmPmToggle(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_amPmToggleData); +} diff --git a/Htmx.ApiDemo/Templates/Greeting.htmx b/Htmx.ApiDemo/Templates/Greeting.htmx index bf76153..1ad6d0f 100644 --- a/Htmx.ApiDemo/Templates/Greeting.htmx +++ b/Htmx.ApiDemo/Templates/Greeting.htmx @@ -1,4 +1,6 @@ -<div class="greeting"> +<div id="Greeting-$$GreetingId$$" class="greeting"> <h1>Hello, $$User$$!</h1> <p>Welcome to high-performance htmx rendering.</p> + + <button hx-get="/greet/$$User$$/$$Count$$/$$GreetingId$$" hx-target="#Greeting-$$GreetingId$$" hx-swap="outerHTML">Click to increase count $$Count$$</button> </div> \ No newline at end of file diff --git a/Htmx.ApiDemo/Templates/Greeting.htmx.cs b/Htmx.ApiDemo/Templates/Greeting.htmx.cs index 30ce110..2091c56 100644 --- a/Htmx.ApiDemo/Templates/Greeting.htmx.cs +++ b/Htmx.ApiDemo/Templates/Greeting.htmx.cs @@ -1,32 +1,38 @@ +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; + namespace Htmx.ApiDemo.Templates; public sealed class Greeting : GreetingBase { private byte[] _userData = []; + private byte[] _countData = []; + private byte[] _greetingIdData = []; public required string Username { init => _userData = value.ToUtf8Bytes(); } + public required int Count { init => _countData = $"{value}".ToUtf8Bytes(); } + public required Guid GreetingId { init => _greetingIdData = $"{value}".ToUtf8Bytes(); } + + protected override void RenderCount(HtmxRenderContext context) => context.Writer.WriteUtf8(_countData); + protected override void RenderGreetingId(HtmxRenderContext context) => context.Writer.WriteUtf8(_greetingIdData); protected override void RenderUser(HtmxRenderContext context) => context.Writer.WriteUtf8(_userData); } [Handler] -[MapGet("/greet/{username}")] +[MapGet("/greet/{username}/{count?}/{id?}")] public static partial class GetGreetingHandler { - public record Query(string Username); + public record Query(string Username, int? Count, Guid? Id); private static ValueTask HandleAsync( Query query, - IHttpContextAccessor httpContextAccessor, + IHttpContextAccessor httpContextAccessor, CancellationToken token) { - var context = httpContextAccessor.HttpContext; - if(context is null) - throw new InvalidOperationException("HttpContext is not available."); - - var template = new Greeting { Username = query.Username }; - - context.Response.ContentType = "text/html; charset=utf-8"; - context.Response.BodyWriter.WriteHtmx(template); + var context = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + var template = new Greeting { Username = query.Username, Count = query.Count + 1 ?? 0, GreetingId = query.Id ?? Guid.NewGuid() }; + context.WriteHtmxBody(template); return ValueTask.CompletedTask; } } \ No newline at end of file diff --git a/Htmx.ApiDemo/Templates/Login.htmx b/Htmx.ApiDemo/Templates/Login.htmx new file mode 100644 index 0000000..53c2016 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Login.htmx @@ -0,0 +1,45 @@ +<div class="flex min-h-full items-center justify-center py-12"> + <div class="w-full max-w-sm space-y-6"> + + <div class="text-center"> + <h1 class="text-2xl font-bold tracking-tight text-foreground">Sign in</h1> + <p class="mt-1 text-sm text-muted-foreground">Enter your credentials to access your account</p> + </div> + + $$ErrorMessage$$ + + <form method="post" action="/login" class="space-y-4"> + $$AntiforgeryToken$$ + <div class="space-y-2"> + <label class="text-sm font-medium leading-none text-foreground" for="login-email">Email</label> + <input id="login-email" name="email" type="email" required autocomplete="email" + class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm + placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 + focus-visible:ring-ring" + placeholder="you@example.com" /> + </div> + + <div class="space-y-2"> + <label class="text-sm font-medium leading-none text-foreground" for="login-password">Password</label> + <input id="login-password" name="password" type="password" required autocomplete="current-password" + class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm + placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 + focus-visible:ring-ring" + placeholder="••••••••" /> + </div> + + <button type="submit" + class="inline-flex h-9 w-full items-center justify-center rounded-md bg-primary px-4 py-2 + text-sm font-medium text-primary-foreground shadow transition-colors + hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"> + Sign in + </button> + </form> + + <p class="text-center text-sm text-muted-foreground"> + Don't have an account? + <a href="/register" class="font-medium text-primary hover:underline">Sign up</a> + </p> + + </div> +</div> diff --git a/Htmx.ApiDemo/Templates/Login.htmx.cs b/Htmx.ApiDemo/Templates/Login.htmx.cs new file mode 100644 index 0000000..77b81f4 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Login.htmx.cs @@ -0,0 +1,88 @@ +using Htmx.ApiDemo.Data; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Mvc; + +namespace Htmx.ApiDemo.Templates; + +public sealed class Login : LoginBase +{ + private readonly byte[] _errorData; + private readonly byte[] _afTokenData; + + public Login(string? errorMessage = null, string? afToken = null) + { + _errorData = string.IsNullOrEmpty(errorMessage) + ? [] + : $"""<div class="rounded-md bg-destructive/15 px-4 py-3 text-sm text-destructive border border-destructive/30">{System.Web.HttpUtility.HtmlEncode(errorMessage)}</div>""".ToUtf8Bytes(); + + _afTokenData = string.IsNullOrEmpty(afToken) + ? [] + : $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afToken)}" />""".ToUtf8Bytes(); + } + + protected override void RenderErrorMessage(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_errorData); + protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afTokenData); +} + + +[Handler] +[MapGet("/login")] +public static partial class GetLoginHandler +{ + public record Query; + + private static ValueTask HandleAsync( + Query _, + IHttpContextAccessor httpContextAccessor, + IAntiforgery antiforgery, + CancellationToken token) + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + if (ctx.User.Identity?.IsAuthenticated == true) + { + ctx.Response.Redirect("/"); + return ValueTask.CompletedTask; + } + + var afTokens = antiforgery.GetAndStoreTokens(ctx); + ctx.WriteHtmxPage(new Login(afToken: afTokens.RequestToken), title: "Sign in", appName: "HtmxApp", pageTitle: "Sign in"); + return ValueTask.CompletedTask; + } +} + + +[Handler] +[MapPost("/login")] +public static partial class PostLoginHandler +{ + public record Command( + [property: FromForm] string Email, + [property: FromForm] string Password + ); + + private static async ValueTask HandleAsync( + [AsParameters] Command command, + IHttpContextAccessor httpContextAccessor, + IAntiforgery antiforgery, + AuthService authService, + CancellationToken token) + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + var (success, error) = await authService.LoginAsync(command.Email, command.Password); + + if (success) + { + ctx.Response.Redirect("/"); + return; + } + + var afTokens = antiforgery.GetAndStoreTokens(ctx); + ctx.WriteHtmxPage(new Login(error, afToken: afTokens.RequestToken), title: "Sign in", appName: "HtmxApp", pageTitle: "Sign in"); + } +} diff --git a/Htmx.ApiDemo/Templates/Logout.cs b/Htmx.ApiDemo/Templates/Logout.cs new file mode 100644 index 0000000..8b67af7 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Logout.cs @@ -0,0 +1,28 @@ +using Htmx.ApiDemo.Data; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; +using Microsoft.AspNetCore.Mvc; + +namespace Htmx.ApiDemo.Templates; + +[Handler] +[MapPost("/logout")] +public static partial class PostLogoutHandler +{ + // Empty command — [AsParameters] ensures form content-type is accepted + // and antiforgery token in the form is validated by the middleware. + public record Command; + + private static async ValueTask HandleAsync( + [AsParameters] Command _, + AuthService authService, + IHttpContextAccessor httpContextAccessor, + CancellationToken token) + { + await authService.SignOutAsync(); + + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + ctx.Response.Redirect("/login"); + } +} diff --git a/Htmx.ApiDemo/Templates/MainLayout.htmx b/Htmx.ApiDemo/Templates/MainLayout.htmx new file mode 100644 index 0000000..aec463a --- /dev/null +++ b/Htmx.ApiDemo/Templates/MainLayout.htmx @@ -0,0 +1,116 @@ +<!DOCTYPE html> +<html lang="en" class="dark"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>$$Title$$ + + + + + + + +
+ + + + + + +
+ + +
+ + + + + +
$$PageTitle$$
+ + +
+ + + + $$UserSection$$ +
+
+ + +
+ $$Body$$ +
+ +
+ + +
+ + + + + + \ No newline at end of file diff --git a/Htmx.ApiDemo/Templates/MainLayout.htmx.cs b/Htmx.ApiDemo/Templates/MainLayout.htmx.cs new file mode 100644 index 0000000..c88be67 --- /dev/null +++ b/Htmx.ApiDemo/Templates/MainLayout.htmx.cs @@ -0,0 +1,91 @@ +using Htmx.ApiDemo; +using Htmx.ApiDemo.Templates.Components; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; + +namespace Htmx.ApiDemo.Templates; + +public sealed class MainLayout : MainLayoutBase +{ + private byte[] _titleData = []; + private byte[] _appNameData = []; + private byte[] _pageTitleData = []; + private byte[] _userSectionData = []; + + public IHtmxComponent Body { get; } + + public MainLayout(IHtmxComponent body, string title = "App", string appName = "MyApp", + string pageTitle = "Dashboard", string? userName = null, string? afToken = null) + { + Body = body; + _titleData = title.ToUtf8Bytes(); + _appNameData = appName.ToUtf8Bytes(); + _pageTitleData = pageTitle.ToUtf8Bytes(); + + var afInput = string.IsNullOrEmpty(afToken) + ? "" + : $""""""; + + _userSectionData = userName is not null + ? $""" +
+
+ {System.Web.HttpUtility.HtmlEncode(GetInitials(userName))} +
+
+ {afInput} + +
+
+ """.ToUtf8Bytes() + : """ + + Sign in + + """.ToUtf8Bytes(); + } + + private static string GetInitials(string name) + { + var parts = name.Trim().Split(' ', System.StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 2 + ? $"{parts[0][0]}{parts[^1][0]}".ToUpperInvariant() + : name.Length > 0 ? name[0].ToString().ToUpperInvariant() : "?"; + } + + protected override void RenderBody(HtmxRenderContext context) => Body.Render(context); + protected override void RenderTitle(HtmxRenderContext context) => context.Writer.WriteUtf8(_titleData); + protected override void RenderAppName(HtmxRenderContext context) => context.Writer.WriteUtf8(_appNameData); + protected override void RenderPageTitle(HtmxRenderContext context) => context.Writer.WriteUtf8(_pageTitleData); + protected override void RenderUserSection(HtmxRenderContext context) => context.Writer.WriteUtf8(_userSectionData); +} + + +[Handler] +[MapGet("/")] +public static partial class GetIndexHandler +{ + public record Command; + + private static ValueTask HandleAsync( + Command command, + IHttpContextAccessor httpContextAccessor, + CancellationToken token) + { + var context = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + var greet = new Greeting { Username = "Enciphered", Count = 0, GreetingId = Guid.NewGuid() }; + context.WriteHtmxPage(greet, title: "Home", appName: "HtmxApp", pageTitle: "Home"); + + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/Htmx.ApiDemo/Templates/Register.htmx b/Htmx.ApiDemo/Templates/Register.htmx new file mode 100644 index 0000000..c08d497 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Register.htmx @@ -0,0 +1,63 @@ +
+
+ +
+

Create an account

+

Fill in the details below to get started

+
+ + $$ErrorMessage$$ + +
+ $$AntiforgeryToken$$ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +

+ Already have an account? + Sign in +

+ +
+
diff --git a/Htmx.ApiDemo/Templates/Register.htmx.cs b/Htmx.ApiDemo/Templates/Register.htmx.cs new file mode 100644 index 0000000..3ff1d86 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Register.htmx.cs @@ -0,0 +1,99 @@ +using Htmx.ApiDemo.Data; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Mvc; + +namespace Htmx.ApiDemo.Templates; + +public sealed class Register : RegisterBase +{ + private readonly byte[] _errorData; + private readonly byte[] _afTokenData; + + public Register(string? errorMessage = null, string? afToken = null) + { + _errorData = string.IsNullOrEmpty(errorMessage) + ? [] + : $"""
{System.Web.HttpUtility.HtmlEncode(errorMessage)}
""".ToUtf8Bytes(); + + _afTokenData = string.IsNullOrEmpty(afToken) + ? [] + : $"""""".ToUtf8Bytes(); + } + + protected override void RenderErrorMessage(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_errorData); + protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afTokenData); +} + + +[Handler] +[MapGet("/register")] +public static partial class GetRegisterHandler +{ + public record Query; + + private static ValueTask HandleAsync( + Query _, + IHttpContextAccessor httpContextAccessor, + IAntiforgery antiforgery, + CancellationToken token) + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + if (ctx.User.Identity?.IsAuthenticated == true) + { + ctx.Response.Redirect("/"); + return ValueTask.CompletedTask; + } + + var afTokens = antiforgery.GetAndStoreTokens(ctx); + ctx.WriteHtmxPage(new Register(afToken: afTokens.RequestToken), title: "Register", appName: "HtmxApp", pageTitle: "Create account"); + return ValueTask.CompletedTask; + } +} + + +[Handler] +[MapPost("/register")] +public static partial class PostRegisterHandler +{ + public record Command( + [property: FromForm] string Email, + [property: FromForm] string Password, + [property: FromForm] string ConfirmPassword, + [property: FromForm] string? DisplayName + ); + + private static async ValueTask HandleAsync( + [AsParameters] Command command, + IHttpContextAccessor httpContextAccessor, + IAntiforgery antiforgery, + AuthService authService, + CancellationToken token) + { + var ctx = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + if (command.Password != command.ConfirmPassword) + { + var afTokens1 = antiforgery.GetAndStoreTokens(ctx); + ctx.WriteHtmxPage(new Register("Passwords do not match.", afToken: afTokens1.RequestToken), + title: "Register", appName: "HtmxApp", pageTitle: "Create account"); + return; + } + + var (success, error) = await authService.RegisterAsync(command.Email, command.Password, command.DisplayName); + + if (success) + { + ctx.Response.Redirect("/"); + return; + } + + var afTokens2 = antiforgery.GetAndStoreTokens(ctx); + ctx.WriteHtmxPage(new Register(error, afToken: afTokens2.RequestToken), + title: "Register", appName: "HtmxApp", pageTitle: "Create account"); + } +} diff --git a/Htmx.ApiDemo/Templates/UiDemo.htmx b/Htmx.ApiDemo/Templates/UiDemo.htmx new file mode 100644 index 0000000..9ac6605 --- /dev/null +++ b/Htmx.ApiDemo/Templates/UiDemo.htmx @@ -0,0 +1,68 @@ +
+ + +
+

Buttons

+
+ $$BtnDefault$$ + $$BtnDestructive$$ + $$BtnOutline$$ + $$BtnSecondary$$ + $$BtnGhost$$ + $$BtnLink$$ + $$BtnSm$$ + $$BtnLg$$ +
+
+ +
+ + +
+

Inputs

+
+ $$InputText$$ + $$InputEmail$$ + $$InputPassword$$ + $$InputSearch$$ +
+
+ +
+ + +
+

Select

+
+ $$SelectDemo$$ +
+
+ +
+ + +
+

Calendar

+ $$CalendarDemo$$ +
+ +
+ + +
+

Calendar Range

+ $$CalendarRangeDemo$$ +
+ +
+ + +
+

Time Picker

+
+ $$TimePickerDemo$$ + $$TimePicker12hDemo$$ +
+
+ +
diff --git a/Htmx.ApiDemo/Templates/UiDemo.htmx.cs b/Htmx.ApiDemo/Templates/UiDemo.htmx.cs new file mode 100644 index 0000000..fc6bff9 --- /dev/null +++ b/Htmx.ApiDemo/Templates/UiDemo.htmx.cs @@ -0,0 +1,101 @@ +using Htmx.ApiDemo.Templates.Components; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; + +namespace Htmx.ApiDemo.Templates; + +public sealed class UiDemo : UiDemoBase +{ + public IHtmxComponent BtnDefault { get; } + public IHtmxComponent BtnDestructive { get; } + public IHtmxComponent BtnOutline { get; } + public IHtmxComponent BtnSecondary { get; } + public IHtmxComponent BtnGhost { get; } + public IHtmxComponent BtnLink { get; } + public IHtmxComponent BtnSm { get; } + public IHtmxComponent BtnLg { get; } + + public IHtmxComponent InputText { get; } + public IHtmxComponent InputEmail { get; } + public IHtmxComponent InputPassword { get; } + public IHtmxComponent InputSearch { get; } + + public IHtmxComponent SelectDemo { get; } + public IHtmxComponent CalendarDemo { get; } + public IHtmxComponent CalendarRangeDemo{ get; } + public IHtmxComponent TimePickerDemo { get; } + public IHtmxComponent TimePicker12hDemo { get; } + + public UiDemo() + { + BtnDefault = new Button("Default"); + BtnDestructive = new Button("Destructive", variant: "destructive"); + BtnOutline = new Button("Outline", variant: "outline"); + BtnSecondary = new Button("Secondary", variant: "secondary"); + BtnGhost = new Button("Ghost", variant: "ghost"); + BtnLink = new Button("Link", variant: "link"); + BtnSm = new Button("Small", size: "sm"); + BtnLg = new Button("Large", size: "lg"); + + InputText = new Input("username", label: "Username", placeholder: "Enter username"); + InputEmail = new Input("email", inputType: "email", label: "Email", placeholder: "you@example.com"); + InputPassword = new Input("password", inputType: "password", label: "Password", placeholder: "••••••••"); + InputSearch = new Input("search", inputType: "search", label: "Search", placeholder: "Search…", + hxAttrs: "hx-get=\"/search\" hx-trigger=\"keyup changed delay:300ms\" hx-target=\"#search-results\""); + + SelectDemo = new Select( + id: "framework", + label: "Framework", + options: [("htmx", "HTMX"), ("react", "React"), ("vue", "Vue"), ("svelte", "Svelte")], + selectedValue: "htmx", + description: "Choose your preferred framework"); + + CalendarDemo = new Calendar(id: "demo-cal", name: "demo-date"); + CalendarRangeDemo = new CalendarRange(id: "demo-calr", name: "demo-range"); + + TimePickerDemo = new TimePicker(name: "time-24h", label: "Time (24h)"); + TimePicker12hDemo = new TimePicker(name: "time-12h", label: "Time (12h)", use12h: true); + } + + protected override void RenderBtnDefault(HtmxRenderContext ctx) => BtnDefault.Render(ctx); + protected override void RenderBtnDestructive(HtmxRenderContext ctx) => BtnDestructive.Render(ctx); + protected override void RenderBtnOutline(HtmxRenderContext ctx) => BtnOutline.Render(ctx); + protected override void RenderBtnSecondary(HtmxRenderContext ctx) => BtnSecondary.Render(ctx); + protected override void RenderBtnGhost(HtmxRenderContext ctx) => BtnGhost.Render(ctx); + protected override void RenderBtnLink(HtmxRenderContext ctx) => BtnLink.Render(ctx); + protected override void RenderBtnSm(HtmxRenderContext ctx) => BtnSm.Render(ctx); + protected override void RenderBtnLg(HtmxRenderContext ctx) => BtnLg.Render(ctx); + + protected override void RenderInputText(HtmxRenderContext ctx) => InputText.Render(ctx); + protected override void RenderInputEmail(HtmxRenderContext ctx) => InputEmail.Render(ctx); + protected override void RenderInputPassword(HtmxRenderContext ctx) => InputPassword.Render(ctx); + protected override void RenderInputSearch(HtmxRenderContext ctx) => InputSearch.Render(ctx); + + protected override void RenderSelectDemo(HtmxRenderContext ctx) => SelectDemo.Render(ctx); + protected override void RenderCalendarDemo(HtmxRenderContext ctx) => CalendarDemo.Render(ctx); + protected override void RenderCalendarRangeDemo(HtmxRenderContext ctx) => CalendarRangeDemo.Render(ctx); + protected override void RenderTimePickerDemo(HtmxRenderContext ctx) => TimePickerDemo.Render(ctx); + protected override void RenderTimePicker12hDemo(HtmxRenderContext ctx) => TimePicker12hDemo.Render(ctx); +} + + +[Handler] +[MapGet("/ui-demo")] +public static partial class GetUiDemoHandler +{ + public record Query; + + private static ValueTask HandleAsync( + Query query, + IHttpContextAccessor httpContextAccessor, + CancellationToken token) + { + var context = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + var page = new UiDemo(); + context.WriteHtmxPage(page, title: "UI Demo", appName: "HtmxApp", pageTitle: "UI Components"); + + return ValueTask.CompletedTask; + } +} diff --git a/Htmx.ApiDemo/appsettings.json b/Htmx.ApiDemo/appsettings.json index 4d56694..1dd6e4c 100644 --- a/Htmx.ApiDemo/appsettings.json +++ b/Htmx.ApiDemo/appsettings.json @@ -1,4 +1,8 @@ { + "ConnectionStrings": { + "DefaultConnection": "mongodb://localhost:27017" + }, + "MongoDbName": "HtmxAppDb", "Logging": { "LogLevel": { "Default": "Information", diff --git a/Htmx.ApiDemo/package.json b/Htmx.ApiDemo/package.json new file mode 100644 index 0000000..13622c0 --- /dev/null +++ b/Htmx.ApiDemo/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "@tailwindcss/cli": "^4.2.4", + "tailwindcss": "^4.2.4" + } +} diff --git a/Htmx.ApiDemo/wwwroot/css/input.css b/Htmx.ApiDemo/wwwroot/css/input.css new file mode 100644 index 0000000..3d520cd --- /dev/null +++ b/Htmx.ApiDemo/wwwroot/css/input.css @@ -0,0 +1,187 @@ +@import "tailwindcss"; + +@theme { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} + +/* ── Calendar component ───────────────────────────────────────────────── */ +@layer components { + .cal-dow { + @apply text-xs font-medium text-muted-foreground py-1; + } + + .cal-day { + @apply h-9 w-full rounded-md text-sm text-center + text-foreground bg-transparent + hover:bg-accent hover:text-accent-foreground + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring + transition-colors cursor-pointer; + } + + .cal-day-selected { + @apply bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground; + } + + .cal-day-today { + @apply font-semibold underline underline-offset-2; + } + + .cal-nav { + @apply text-lg leading-none; + } + + /* ── Month / year quick-pick grid ── */ + .cal-view-btn { + @apply h-9 w-full rounded-md text-sm text-center + text-foreground bg-transparent + hover:bg-accent hover:text-accent-foreground + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring + transition-colors cursor-pointer; + } + + .cal-view-btn-selected { + @apply bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground; + } + + /* ── CalendarRange day states ── */ + .calr-day { + @apply h-9 w-full text-sm text-center text-foreground + transition-colors cursor-pointer focus-visible:outline-none + focus-visible:ring-2 focus-visible:ring-ring; + } + + /* Plain days (no range involvement) */ + .calr-day-plain { + @apply rounded-md hover:bg-accent hover:text-accent-foreground; + } + + /* Start cap — primary, rounded left only */ + .calr-day-start { + @apply rounded-l-md bg-primary text-primary-foreground + hover:bg-primary; + } + + /* End cap — primary, rounded right only */ + .calr-day-end { + @apply rounded-r-md bg-primary text-primary-foreground + hover:bg-primary; + } + + /* Days strictly between start and end */ + .calr-day-mid { + @apply rounded-none bg-accent text-accent-foreground + hover:bg-accent; + } +} + +/* ── Select – custom caret via background SVG ─────────────────────────── */ +@layer components { + select.appearance-none { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.25rem; + } +} + +/* ── TimePicker – hide number spinner arrows ──────────────────────────── */ +@layer utilities { + input[type=number].timepicker-hour, + input[type=number].timepicker-minute { + -moz-appearance: textfield; + } + input[type=number].timepicker-hour::-webkit-outer-spin-button, + input[type=number].timepicker-hour::-webkit-inner-spin-button, + input[type=number].timepicker-minute::-webkit-outer-spin-button, + input[type=number].timepicker-minute::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } +} diff --git a/Htmx.ApiDemo/wwwroot/css/output.css b/Htmx.ApiDemo/wwwroot/css/output.css new file mode 100644 index 0000000..c5a2acc --- /dev/null +++ b/Htmx.ApiDemo/wwwroot/css/output.css @@ -0,0 +1,1365 @@ +/*! tailwindcss v4.2.4 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + --color-black: #000; + --spacing: 0.25rem; + --container-xs: 20rem; + --container-sm: 24rem; + --container-xl: 36rem; + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --tracking-tight: -0.025em; + --radius-md: calc(var(--radius) - 2px); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} +@layer utilities { + .pointer-events-none { + pointer-events: none; + } + .fixed { + position: fixed; + } + .static { + position: static; + } + .inset-0 { + inset: calc(var(--spacing) * 0); + } + .inset-y-0 { + inset-block: calc(var(--spacing) * 0); + } + .-start { + inset-inline-start: calc(var(--spacing) * -1); + } + .start { + inset-inline-start: var(--spacing); + } + .\!end { + inset-inline-end: var(--spacing) !important; + } + .-end { + inset-inline-end: calc(var(--spacing) * -1); + } + .end { + inset-inline-end: var(--spacing); + } + .left-0 { + left: calc(var(--spacing) * 0); + } + .z-20 { + z-index: 20; + } + .z-30 { + z-index: 30; + } + .mt-1 { + margin-top: calc(var(--spacing) * 1); + } + .mt-3 { + margin-top: calc(var(--spacing) * 3); + } + .mb-1 { + margin-bottom: calc(var(--spacing) * 1); + } + .mb-3 { + margin-bottom: calc(var(--spacing) * 3); + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .flex { + display: flex; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .inline { + display: inline; + } + .inline-block { + display: inline-block; + } + .inline-flex { + display: inline-flex; + } + .h-4 { + height: calc(var(--spacing) * 4); + } + .h-5 { + height: calc(var(--spacing) * 5); + } + .h-8 { + height: calc(var(--spacing) * 8); + } + .h-9 { + height: calc(var(--spacing) * 9); + } + .h-10 { + height: calc(var(--spacing) * 10); + } + .h-11 { + height: calc(var(--spacing) * 11); + } + .h-16 { + height: calc(var(--spacing) * 16); + } + .min-h-4 { + min-height: calc(var(--spacing) * 4); + } + .min-h-dvh { + min-height: 100dvh; + } + .min-h-full { + min-height: 100%; + } + .w-4 { + width: calc(var(--spacing) * 4); + } + .w-5 { + width: calc(var(--spacing) * 5); + } + .w-8 { + width: calc(var(--spacing) * 8); + } + .w-9 { + width: calc(var(--spacing) * 9); + } + .w-10 { + width: calc(var(--spacing) * 10); + } + .w-16 { + width: calc(var(--spacing) * 16); + } + .w-64 { + width: calc(var(--spacing) * 64); + } + .w-full { + width: 100%; + } + .max-w-sm { + max-width: var(--container-sm); + } + .max-w-xl { + max-width: var(--container-xl); + } + .max-w-xs { + max-width: var(--container-xs); + } + .min-w-72 { + min-width: calc(var(--spacing) * 72); + } + .flex-1 { + flex: 1; + } + .shrink-0 { + flex-shrink: 0; + } + .-translate-x-full { + --tw-translate-x: -100%; + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .cursor-pointer { + cursor: pointer; + } + .appearance-none { + appearance: none; + } + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .grid-cols-7 { + grid-template-columns: repeat(7, minmax(0, 1fr)); + } + .flex-col { + flex-direction: column; + } + .flex-wrap { + flex-wrap: wrap; + } + .items-center { + align-items: center; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .gap-0\.5 { + gap: calc(var(--spacing) * 0.5); + } + .gap-1 { + gap: calc(var(--spacing) * 1); + } + .gap-1\.5 { + gap: calc(var(--spacing) * 1.5); + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-3 { + gap: calc(var(--spacing) * 3); + } + .gap-4 { + gap: calc(var(--spacing) * 4); + } + .gap-8 { + gap: calc(var(--spacing) * 8); + } + .space-y-1 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-2 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-4 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-6 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-10 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse))); + } + } + .overflow-hidden { + overflow: hidden; + } + .overflow-y-auto { + overflow-y: auto; + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .rounded-md { + border-radius: var(--radius-md); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .border-r { + border-right-style: var(--tw-border-style); + border-right-width: 1px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-border { + border-color: var(--color-border); + } + .border-destructive\/30 { + border-color: color-mix(in srgb, hsl(var(--destructive)) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-destructive) 30%, transparent); + } + } + .border-input { + border-color: var(--color-input); + } + .bg-background { + background-color: var(--color-background); + } + .bg-black\/50 { + background-color: color-mix(in srgb, #000 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 50%, transparent); + } + } + .bg-card { + background-color: var(--color-card); + } + .bg-card\/80 { + background-color: color-mix(in srgb, hsl(var(--card)) 80%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-card) 80%, transparent); + } + } + .bg-destructive { + background-color: var(--color-destructive); + } + .bg-destructive\/15 { + background-color: color-mix(in srgb, hsl(var(--destructive)) 15%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-destructive) 15%, transparent); + } + } + .bg-primary { + background-color: var(--color-primary); + } + .bg-secondary { + background-color: var(--color-secondary); + } + .bg-transparent { + background-color: transparent; + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .px-5 { + padding-inline: calc(var(--spacing) * 5); + } + .px-8 { + padding-inline: calc(var(--spacing) * 8); + } + .py-0\.5 { + padding-block: calc(var(--spacing) * 0.5); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + .py-12 { + padding-block: calc(var(--spacing) * 12); + } + .text-center { + text-align: center; + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .leading-none { + --tw-leading: 1; + line-height: 1; + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .tracking-tight { + --tw-tracking: var(--tracking-tight); + letter-spacing: var(--tracking-tight); + } + .whitespace-nowrap { + white-space: nowrap; + } + .text-destructive { + color: var(--color-destructive); + } + .text-destructive-foreground { + color: var(--color-destructive-foreground); + } + .text-foreground { + color: var(--color-foreground); + } + .text-muted-foreground { + color: var(--color-muted-foreground); + } + .text-primary { + color: var(--color-primary); + } + .text-primary-foreground { + color: var(--color-primary-foreground); + } + .text-secondary-foreground { + color: var(--color-secondary-foreground); + } + .underline-offset-4 { + text-underline-offset: 4px; + } + .antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + .opacity-0 { + opacity: 0%; + } + .shadow { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-lg { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .ring-offset-background { + --tw-ring-offset-color: var(--color-background); + } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .backdrop-blur { + --tw-backdrop-blur: blur(8px); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + .transition-colors { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-opacity { + transition-property: opacity; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .duration-300 { + --tw-duration: 300ms; + transition-duration: 300ms; + } + .ease-in-out { + --tw-ease: var(--ease-in-out); + transition-timing-function: var(--ease-in-out); + } + .select-none { + -webkit-user-select: none; + user-select: none; + } + .peer-disabled\:cursor-not-allowed { + &:is(:where(.peer):disabled ~ *) { + cursor: not-allowed; + } + } + .peer-disabled\:opacity-70 { + &:is(:where(.peer):disabled ~ *) { + opacity: 70%; + } + } + .placeholder\:text-muted-foreground { + &::placeholder { + color: var(--color-muted-foreground); + } + } + .hover\:bg-accent { + &:hover { + @media (hover: hover) { + background-color: var(--color-accent); + } + } + } + .hover\:bg-destructive\/90 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, hsl(var(--destructive)) 90%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-destructive) 90%, transparent); + } + } + } + } + .hover\:bg-primary\/90 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, hsl(var(--primary)) 90%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-primary) 90%, transparent); + } + } + } + } + .hover\:bg-secondary\/80 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, hsl(var(--secondary)) 80%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-secondary) 80%, transparent); + } + } + } + } + .hover\:text-accent-foreground { + &:hover { + @media (hover: hover) { + color: var(--color-accent-foreground); + } + } + } + .hover\:underline { + &:hover { + @media (hover: hover) { + text-decoration-line: underline; + } + } + } + .focus\:ring-2 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-ring { + &:focus { + --tw-ring-color: var(--color-ring); + } + } + .focus\:ring-offset-2 { + &:focus { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } + } + .focus\:outline-none { + &:focus { + --tw-outline-style: none; + outline-style: none; + } + } + .focus-visible\:ring-1 { + &:focus-visible { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus-visible\:ring-2 { + &:focus-visible { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus-visible\:ring-ring { + &:focus-visible { + --tw-ring-color: var(--color-ring); + } + } + .focus-visible\:ring-offset-2 { + &:focus-visible { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } + } + .focus-visible\:outline-none { + &:focus-visible { + --tw-outline-style: none; + outline-style: none; + } + } + .disabled\:pointer-events-none { + &:disabled { + pointer-events: none; + } + } + .disabled\:cursor-not-allowed { + &:disabled { + cursor: not-allowed; + } + } + .disabled\:opacity-50 { + &:disabled { + opacity: 50%; + } + } + .sm\:grid-cols-2 { + @media (width >= 40rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .md\:relative { + @media (width >= 48rem) { + position: relative; + } + } + .md\:hidden { + @media (width >= 48rem) { + display: none; + } + } + .md\:translate-x-0 { + @media (width >= 48rem) { + --tw-translate-x: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + .md\:px-6 { + @media (width >= 48rem) { + padding-inline: calc(var(--spacing) * 6); + } + } + .md\:shadow-none { + @media (width >= 48rem) { + --tw-shadow: 0 0 #0000; + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .\[\&\.open\]\:translate-x-0 { + &.open { + --tw-translate-x: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } +} +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} +@layer base { + * { + border-color: var(--color-border); + } + body { + background-color: var(--color-background); + color: var(--color-foreground); + } +} +@layer components { + .cal-dow { + padding-block: calc(var(--spacing) * 1); + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + color: var(--color-muted-foreground); + } + .cal-day { + height: calc(var(--spacing) * 9); + width: 100%; + cursor: pointer; + border-radius: var(--radius-md); + background-color: transparent; + text-align: center; + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + color: var(--color-foreground); + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + &:hover { + @media (hover: hover) { + background-color: var(--color-accent); + } + } + &:hover { + @media (hover: hover) { + color: var(--color-accent-foreground); + } + } + &:focus-visible { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + &:focus-visible { + --tw-ring-color: var(--color-ring); + } + &:focus-visible { + --tw-outline-style: none; + outline-style: none; + } + } + .cal-day-selected { + background-color: var(--color-primary); + color: var(--color-primary-foreground); + &:hover { + @media (hover: hover) { + background-color: var(--color-primary); + } + } + &:hover { + @media (hover: hover) { + color: var(--color-primary-foreground); + } + } + } + .cal-day-today { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + text-decoration-line: underline; + text-underline-offset: 2px; + } + .cal-nav { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + --tw-leading: 1; + line-height: 1; + } + .cal-view-btn { + height: calc(var(--spacing) * 9); + width: 100%; + cursor: pointer; + border-radius: var(--radius-md); + background-color: transparent; + text-align: center; + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + color: var(--color-foreground); + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + &:hover { + @media (hover: hover) { + background-color: var(--color-accent); + } + } + &:hover { + @media (hover: hover) { + color: var(--color-accent-foreground); + } + } + &:focus-visible { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + &:focus-visible { + --tw-ring-color: var(--color-ring); + } + &:focus-visible { + --tw-outline-style: none; + outline-style: none; + } + } + .cal-view-btn-selected { + background-color: var(--color-primary); + color: var(--color-primary-foreground); + &:hover { + @media (hover: hover) { + background-color: var(--color-primary); + } + } + &:hover { + @media (hover: hover) { + color: var(--color-primary-foreground); + } + } + } + .calr-day { + height: calc(var(--spacing) * 9); + width: 100%; + cursor: pointer; + text-align: center; + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + color: var(--color-foreground); + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + &:focus-visible { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + &:focus-visible { + --tw-ring-color: var(--color-ring); + } + &:focus-visible { + --tw-outline-style: none; + outline-style: none; + } + } + .calr-day-plain { + border-radius: var(--radius-md); + &:hover { + @media (hover: hover) { + background-color: var(--color-accent); + } + } + &:hover { + @media (hover: hover) { + color: var(--color-accent-foreground); + } + } + } + .calr-day-start { + border-top-left-radius: var(--radius-md); + border-bottom-left-radius: var(--radius-md); + background-color: var(--color-primary); + color: var(--color-primary-foreground); + &:hover { + @media (hover: hover) { + background-color: var(--color-primary); + } + } + } + .calr-day-end { + border-top-right-radius: var(--radius-md); + border-bottom-right-radius: var(--radius-md); + background-color: var(--color-primary); + color: var(--color-primary-foreground); + &:hover { + @media (hover: hover) { + background-color: var(--color-primary); + } + } + } + .calr-day-mid { + border-radius: 0; + background-color: var(--color-accent); + color: var(--color-accent-foreground); + &:hover { + @media (hover: hover) { + background-color: var(--color-accent); + } + } + } +} +@layer components { + select.appearance-none { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.25rem; + } +} +@layer utilities { + input[type=number].timepicker-hour, input[type=number].timepicker-minute { + -moz-appearance: textfield; + } + input[type=number].timepicker-hour::-webkit-outer-spin-button, input[type=number].timepicker-hour::-webkit-inner-spin-button, input[type=number].timepicker-minute::-webkit-outer-spin-button, input[type=number].timepicker-minute::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } +} +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-duration { + syntax: "*"; + inherits: false; +} +@property --tw-ease { + syntax: "*"; + inherits: false; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-space-y-reverse: 0; + --tw-border-style: solid; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; + --tw-duration: initial; + --tw-ease: initial; + } + } +} diff --git a/Htmx.ApiDemo/wwwroot/js/components.js b/Htmx.ApiDemo/wwwroot/js/components.js new file mode 100644 index 0000000..2b360e9 --- /dev/null +++ b/Htmx.ApiDemo/wwwroot/js/components.js @@ -0,0 +1,458 @@ +/* ───────────────────────────────────────────────────────────────────────── + * components.js – client-side logic for htmx server-rendered components + * ───────────────────────────────────────────────────────────────────────── */ + +// ── Calendar ────────────────────────────────────────────────────────────── + +(function () { + var MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + + function renderCalendar(root) { + var view = root.dataset.view || 'days'; + var year = parseInt(root.dataset.year, 10); + var month = parseInt(root.dataset.month, 10); + var selD = parseInt(root.dataset.selDay, 10); + var selM = parseInt(root.dataset.selMonth, 10); + var selY = parseInt(root.dataset.selYear, 10); + + var labelBtn = root.querySelector('.cal-month-label'); + var grid = root.querySelector('.cal-grid'); + var dowRow = root.querySelector('.cal-dow-row'); + + // ── Update header label based on view ── + if (view === 'days') { + labelBtn.textContent = MONTHS[month] + ' ' + year; + } else if (view === 'months') { + labelBtn.textContent = year; + } else { // years + var ds = Math.floor(year / 12) * 12; + labelBtn.textContent = ds + ' – ' + (ds + 11); + } + + // Show DOW row only in day view + if (dowRow) dowRow.style.display = view === 'days' ? '' : 'none'; + + grid.innerHTML = ''; + + if (view === 'days') { + grid.style.gridTemplateColumns = ''; // let CSS class (grid-cols-7) take over + + var firstDay = new Date(year, month, 1).getDay(); + var daysInMonth = new Date(year, month + 1, 0).getDate(); + + for (var i = 0; i < firstDay; i++) { + grid.appendChild(document.createElement('div')); + } + + for (var d = 1; d <= daysInMonth; d++) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = d; + btn.className = 'cal-day'; + + if (d === selD && month === selM && year === selY) { + btn.classList.add('cal-day-selected'); + } + + btn.dataset.date = year + '-' + + String(month + 1).padStart(2, '0') + '-' + + String(d).padStart(2, '0'); + + btn.addEventListener('click', (function (b, r) { + return function () { + var parts = b.dataset.date.split('-'); + r.dataset.selYear = parts[0]; + r.dataset.selMonth = parseInt(parts[1], 10) - 1; + r.dataset.selDay = parseInt(parts[2], 10); + r.querySelectorAll('.cal-day').forEach(function (el) { + el.classList.remove('cal-day-selected'); + }); + b.classList.add('cal-day-selected'); + r.querySelector('.cal-hidden-input').value = b.dataset.date; + r.dispatchEvent(new CustomEvent('calendarChange', { + detail: { date: b.dataset.date }, + bubbles: true + })); + }; + })(btn, root)); + + grid.appendChild(btn); + } + + } else if (view === 'months') { + grid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))'; + + MONTHS.forEach(function (name, i) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = name.slice(0, 3); + btn.className = 'cal-view-btn' + (i === month ? ' cal-view-btn-selected' : ''); + btn.addEventListener('click', function () { + root.dataset.month = i; + root.dataset.view = 'days'; + renderCalendar(root); + }); + grid.appendChild(btn); + }); + + } else { // years + var decadeStart = Math.floor(year / 12) * 12; + grid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))'; + + for (var yi = 0; yi < 12; yi++) { + (function (y) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = y; + btn.className = 'cal-view-btn' + (y === year ? ' cal-view-btn-selected' : ''); + btn.addEventListener('click', function () { + root.dataset.year = y; + root.dataset.view = 'months'; + renderCalendar(root); + }); + grid.appendChild(btn); + })(decadeStart + yi); + } + } + } + + function initCalendar(root) { + root.querySelector('.cal-prev').addEventListener('click', function () { + var view = root.dataset.view || 'days'; + var m = parseInt(root.dataset.month, 10); + var y = parseInt(root.dataset.year, 10); + if (view === 'days') { + if (m === 0) { m = 11; y--; } else { m--; } + root.dataset.month = m; + root.dataset.year = y; + } else if (view === 'months') { + root.dataset.year = y - 1; + } else { // years + root.dataset.year = Math.floor(y / 12) * 12 - 12; + } + renderCalendar(root); + }); + + root.querySelector('.cal-next').addEventListener('click', function () { + var view = root.dataset.view || 'days'; + var m = parseInt(root.dataset.month, 10); + var y = parseInt(root.dataset.year, 10); + if (view === 'days') { + if (m === 11) { m = 0; y++; } else { m++; } + root.dataset.month = m; + root.dataset.year = y; + } else if (view === 'months') { + root.dataset.year = y + 1; + } else { // years + root.dataset.year = Math.floor(y / 12) * 12 + 12; + } + renderCalendar(root); + }); + + root.querySelector('.cal-month-label').addEventListener('click', function () { + var view = root.dataset.view || 'days'; + if (view === 'days') root.dataset.view = 'months'; + else if (view === 'months') root.dataset.view = 'years'; + // already at years — nothing deeper + renderCalendar(root); + }); + + renderCalendar(root); + } + + // Initialise all calendars on page load, and again after any htmx swap + function initAll() { + document.querySelectorAll('.calendar-root').forEach(initCalendar); + } + + document.addEventListener('DOMContentLoaded', initAll); + document.addEventListener('htmx:afterSwap', initAll); +})(); + + +// ── CalendarRange ───────────────────────────────────────────────────────── + +(function () { + var MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + + function cmpDate(a, b) { return a < b ? -1 : a > b ? 1 : 0; } + + function isBetween(d, start, end) { + return cmpDate(d, start) > 0 && cmpDate(d, end) < 0; + } + + function toDateStr(year, month, day) { + return year + '-' + + String(month + 1).padStart(2, '0') + '-' + + String(day).padStart(2, '0'); + } + + function updateLabel(root) { + var lbl = root.querySelector('.calr-label'); + if (!lbl) return; + var start = root.dataset.start; + var end = root.dataset.end; + if (start && end) { lbl.textContent = start + ' → ' + end; return; } + if (start) { lbl.textContent = start + ' → pick end date'; return; } + lbl.textContent = ''; + } + + // Only updates CSS classes on already-rendered buttons — no DOM destruction. + function updateHoverClasses(root, hoverDate) { + var start = root.dataset.start; + var end = root.dataset.end; + var rangeEnd = (start && !end && hoverDate && cmpDate(hoverDate, start) >= 0) + ? hoverDate : end; + + root.querySelectorAll('.calr-day').forEach(function (btn) { + var ds = btn.dataset.date; + var isStart = !!(start && ds === start); + var isEnd = !!(end && ds === end); + var isHoverEnd = !!(!end && start && hoverDate && ds === hoverDate + && cmpDate(hoverDate, start) > 0); + var isMid = !!(start && rangeEnd && isBetween(ds, start, rangeEnd)); + + btn.classList.remove('calr-day-start', 'calr-day-end', 'calr-day-mid', 'calr-day-plain'); + if (isStart) btn.classList.add('calr-day-start'); + if (isEnd || isHoverEnd) btn.classList.add('calr-day-end'); + if (isMid) btn.classList.add('calr-day-mid'); + if (!isStart && !isEnd && !isHoverEnd && !isMid) btn.classList.add('calr-day-plain'); + }); + } + + // Full re-render of the grid. Called on mount, click, and view changes. + function renderRange(root) { + var view = root.dataset.view || 'days'; + var year = parseInt(root.dataset.year, 10); + var month = parseInt(root.dataset.month, 10); + var start = root.dataset.start || ''; + var end = root.dataset.end || ''; + + var labelBtn = root.querySelector('.calr-month-label'); + var grid = root.querySelector('.calr-grid'); + var dowRow = root.querySelector('.cal-dow-row'); + + // ── Update header label ── + if (view === 'days') { + labelBtn.textContent = MONTHS[month] + ' ' + year; + } else if (view === 'months') { + labelBtn.textContent = year; + } else { // years + var ds = Math.floor(year / 12) * 12; + labelBtn.textContent = ds + ' – ' + (ds + 11); + } + + if (dowRow) dowRow.style.display = view === 'days' ? '' : 'none'; + + grid.innerHTML = ''; + + // ── Clear event handlers (will be reassigned per view below) ── + grid.onmouseover = null; + grid.onmouseleave = null; + + if (view === 'days') { + grid.style.gridTemplateColumns = ''; + + var firstDay = new Date(year, month, 1).getDay(); + var daysInMonth = new Date(year, month + 1, 0).getDate(); + + for (var i = 0; i < firstDay; i++) { + grid.appendChild(document.createElement('div')); + } + + for (var d = 1; d <= daysInMonth; d++) { + var dateStr = toDateStr(year, month, d); + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = d; + btn.dataset.date = dateStr; + + var isStart = start && dateStr === start; + var isEnd = end && dateStr === end; + var isMid = start && end && isBetween(dateStr, start, end); + var cls = 'calr-day'; + if (isStart) cls += ' calr-day-start'; + else if (isEnd) cls += ' calr-day-end'; + else if (isMid) cls += ' calr-day-mid'; + else cls += ' calr-day-plain'; + btn.className = cls; + + grid.appendChild(btn); + } + + // Click: update state → full re-render + grid.onclick = function (e) { + var btn = e.target.closest('.calr-day'); + if (!btn) return; + var ds = btn.dataset.date; + var s = root.dataset.start; + var en = root.dataset.end; + + if (!s || (s && en)) { + root.dataset.start = ds; + root.dataset.end = ''; + } else { + if (cmpDate(ds, s) > 0) { + root.dataset.end = ds; + } else if (cmpDate(ds, s) < 0) { + root.dataset.start = ds; + root.dataset.end = ''; + } else { + root.dataset.start = ''; + root.dataset.end = ''; + } + } + + root.querySelector('.calr-hidden-start').value = root.dataset.start; + root.querySelector('.calr-hidden-end').value = root.dataset.end; + root.dispatchEvent(new CustomEvent('rangeChange', { + detail: { start: root.dataset.start, end: root.dataset.end }, + bubbles: true + })); + + renderRange(root); + updateLabel(root); + }; + + grid.onmouseover = function (e) { + var btn = e.target.closest('.calr-day'); + if (!btn) return; + updateHoverClasses(root, btn.dataset.date); + }; + + grid.onmouseleave = function () { + updateHoverClasses(root, null); + }; + + } else if (view === 'months') { + grid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))'; + + MONTHS.forEach(function (name, i) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = name.slice(0, 3); + btn.className = 'cal-view-btn' + (i === month ? ' cal-view-btn-selected' : ''); + btn.addEventListener('click', function () { + root.dataset.month = i; + root.dataset.view = 'days'; + renderRange(root); + }); + grid.appendChild(btn); + }); + + } else { // years + var decadeStart = Math.floor(year / 12) * 12; + grid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))'; + + for (var yi = 0; yi < 12; yi++) { + (function (y) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = y; + btn.className = 'cal-view-btn' + (y === year ? ' cal-view-btn-selected' : ''); + btn.addEventListener('click', function () { + root.dataset.year = y; + root.dataset.view = 'months'; + renderRange(root); + }); + grid.appendChild(btn); + })(decadeStart + yi); + } + } + } + + function initCalendarRange(root) { + root.querySelector('.calr-prev').addEventListener('click', function () { + var view = root.dataset.view || 'days'; + var m = parseInt(root.dataset.month, 10); + var y = parseInt(root.dataset.year, 10); + if (view === 'days') { + if (m === 0) { m = 11; y--; } else { m--; } + root.dataset.month = m; + root.dataset.year = y; + } else if (view === 'months') { + root.dataset.year = y - 1; + } else { // years + root.dataset.year = Math.floor(y / 12) * 12 - 12; + } + renderRange(root); + }); + + root.querySelector('.calr-next').addEventListener('click', function () { + var view = root.dataset.view || 'days'; + var m = parseInt(root.dataset.month, 10); + var y = parseInt(root.dataset.year, 10); + if (view === 'days') { + if (m === 11) { m = 0; y++; } else { m++; } + root.dataset.month = m; + root.dataset.year = y; + } else if (view === 'months') { + root.dataset.year = y + 1; + } else { // years + root.dataset.year = Math.floor(y / 12) * 12 + 12; + } + renderRange(root); + }); + + root.querySelector('.calr-month-label').addEventListener('click', function () { + var view = root.dataset.view || 'days'; + if (view === 'days') root.dataset.view = 'months'; + else if (view === 'months') root.dataset.view = 'years'; + renderRange(root); + }); + + renderRange(root); + updateLabel(root); + } + + function initAll() { + document.querySelectorAll('.calr-root').forEach(initCalendarRange); + } + + document.addEventListener('DOMContentLoaded', initAll); + document.addEventListener('htmx:afterSwap', initAll); +})(); + + +// ── TimePicker ──────────────────────────────────────────────────────────── + +(function () { + function syncTime(root) { + var h = parseInt(root.querySelector('.timepicker-hour').value, 10) || 0; + var m = parseInt(root.querySelector('.timepicker-minute').value, 10) || 0; + var use12h = root.dataset.use12h === 'true'; + var h24 = h; + + if (use12h) { + var ampmEl = root.querySelector('.timepicker-ampm'); + var ampm = ampmEl ? ampmEl.value : 'AM'; + if (ampm === 'PM') { h24 = h === 12 ? 12 : h + 12; } + else { h24 = h === 12 ? 0 : h; } + } + + root.querySelector('.timepicker-hidden').value = + String(h24).padStart(2, '0') + ':' + String(m).padStart(2, '0'); + } + + function initTimePicker(root) { + var sync = syncTime.bind(null, root); + root.querySelector('.timepicker-hour').addEventListener('input', sync); + root.querySelector('.timepicker-minute').addEventListener('input', sync); + var ampmEl = root.querySelector('.timepicker-ampm'); + if (ampmEl) ampmEl.addEventListener('change', sync); + sync(); + } + + function initAll() { + document.querySelectorAll('.timepicker-root').forEach(initTimePicker); + } + + document.addEventListener('DOMContentLoaded', initAll); + document.addEventListener('htmx:afterSwap', initAll); +})(); diff --git a/Htmx.SourceGenerator/HtmxGenerator.cs b/Htmx.SourceGenerator/HtmxGenerator.cs index fe829c2..3fbf095 100644 --- a/Htmx.SourceGenerator/HtmxGenerator.cs +++ b/Htmx.SourceGenerator/HtmxGenerator.cs @@ -1,4 +1,14 @@ -namespace Htmx.SourceGenerator +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Htmx.SourceGenerator { [Generator] public class HtmxGenerator : IIncrementalGenerator