Added components, authentication and authorization

This commit is contained in:
2026-05-04 16:53:19 +05:00
parent 493cd71d17
commit fb1cb8e834
37 changed files with 3545 additions and 21 deletions
+2
View File
@@ -1,2 +1,4 @@
bin bin
obj obj
node_modules
package-lock.json
+4
View File
@@ -1,8 +1,12 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Htmx.ApiDemo.Templates;
namespace Htmx.ApiDemo; namespace Htmx.ApiDemo;
[JsonSerializable(typeof(string))] [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 internal partial class AppJsonSerializerContext : JsonSerializerContext
{ {
+31
View File
@@ -0,0 +1,31 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Htmx.ApiDemo.Data;
/// <summary>
/// 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.
/// </summary>
public sealed class AppUser
{
[BsonId]
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
[BsonElement("email")]
public string Email { get; set; } = "";
/// <summary>Email.ToUpperInvariant() — used for case-insensitive lookups.</summary>
[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;
}
+83
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class AuthService(
MongoDbService mongo,
IPasswordHasher<AppUser> 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<Claim> 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);
}
}
+47
View File
@@ -0,0 +1,47 @@
using MongoDB.Bson;
using MongoDB.Driver;
namespace Htmx.ApiDemo.Data;
/// <summary>
/// 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.
/// </summary>
public sealed class MongoDbService
{
private readonly IMongoCollection<AppUser> _users;
public MongoDbService(IMongoClient client, IConfiguration configuration)
{
var db = client.GetDatabase(configuration["MongoDbName"] ?? "HtmxAppDb");
_users = db.GetCollection<AppUser>("users");
}
/// <summary>Ensures the unique index on NormalizedEmail exists (idempotent).</summary>
public async Task EnsureIndexesAsync(CancellationToken ct = default)
{
var indexKeys = Builders<AppUser>.IndexKeys.Ascending(u => u.NormalizedEmail);
var indexOptions = new CreateIndexOptions { Unique = true, Name = "uq_normalizedEmail" };
var model = new CreateIndexModel<AppUser>(indexKeys, indexOptions);
await _users.Indexes.CreateOneAsync(model, cancellationToken: ct);
}
/// <summary>Returns true if a user with the given normalised email already exists.</summary>
public async Task<bool> EmailExistsAsync(string normalizedEmail, CancellationToken ct = default)
{
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
return await _users.Find(filter).AnyAsync(ct);
}
/// <summary>Returns the user matching the normalised email, or null.</summary>
public async Task<AppUser?> FindByNormalizedEmailAsync(string normalizedEmail, CancellationToken ct = default)
{
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
return await _users.Find(filter).FirstOrDefaultAsync(ct);
}
/// <summary>Inserts a new user document.</summary>
public Task InsertAsync(AppUser user, CancellationToken ct = default) =>
_users.InsertOneAsync(user, cancellationToken: ct);
}
+18 -5
View File
@@ -4,30 +4,43 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>true</PublishAot> <PublishAot>true</PublishAot>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>obj/Generated</CompilerGeneratedFilesOutputPath> <CompilerGeneratedFilesOutputPath>obj/Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup> </PropertyGroup>
<PropertyGroup>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
<PublishAot>true</PublishAot>
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Immediate.Apis.Generators</InterceptorsPreviewNamespaces>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<CompilerVisibleProperty Include="RootNamespace" /> <CompilerVisibleProperty Include="RootNamespace" />
<CompilerVisibleProperty Include="MSBuildProjectDirectory" /> <CompilerVisibleProperty Include="MSBuildProjectDirectory" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Update="wwwroot\**" CopyToPublishDirectory="Always" />
<Content Remove="wwwroot\css\output.css" />
<AdditionalFiles Include="**/*.htmx" /> <AdditionalFiles Include="**/*.htmx" />
<None Remove="**/*.htmx" /> <None Remove="**/*.htmx" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Immediate.Apis" Version="4.2.0" /> <PackageReference Include="Immediate.Apis" Version="4.2.0" />
<PackageReference Include="Immediate.Handlers" Version="3.5.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<ProjectReference Include="..\Htmx.SourceGenerator\Htmx.SourceGenerator.csproj" /> <PackageReference Include="MongoDB.Driver" Version="3.4.0" />
<ProjectReference Include="..\Htmx.SourceGenerator\Htmx.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup> </ItemGroup>
<ItemGroup> <Target Name="Tailwind" BeforeTargets="Build" Inputs="./wwwroot/css/input.css;./**/*.htmx;./**/*.cshtml" Outputs="./wwwroot/css/output.css">
<ProjectReference Include="..\Htmx.SourceGenerator\Htmx.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> <PropertyGroup>
</ItemGroup> <TailwindMinify Condition="'$(Configuration)' == 'Release'">--minify</TailwindMinify>
</PropertyGroup>
<Exec Command="npx @tailwindcss/cli -i ./wwwroot/css/input.css -o ./wwwroot/css/output.css $(TailwindMinify)" />
</Target>
</Project> </Project>
+44
View File
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Antiforgery;
namespace Htmx.ApiDemo;
/// <summary>
/// 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
/// </summary>
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 <title> 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));
}
}
}
+78
View File
@@ -1,22 +1,100 @@
using Htmx.ApiDemo; 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); var builder = WebApplication.CreateSlimBuilder(args);
// ── Antiforgery ───────────────────────────────────────────────────────────
builder.Services.AddAntiforgery();
// ── JSON ──────────────────────────────────────────────────────────────────
builder.Services.ConfigureHttpJsonOptions(options => builder.Services.ConfigureHttpJsonOptions(options =>
{ {
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); 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.AddHttpContextAccessor();
builder.Services builder.Services
.AddHtmxApiDemoBehaviors() .AddHtmxApiDemoBehaviors()
.AddHtmxApiDemoHandlers(); .AddHtmxApiDemoHandlers();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddAuthorization();
var app = builder.Build(); 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()) if (app.Environment.IsDevelopment())
app.MapOpenApi(); 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.MapHtmxApiDemoEndpoints();
app.Run(); app.Run();
@@ -0,0 +1 @@
<button type="$$Type$$" class="$$Classes$$" $$HxAttrs$$>$$Label$$</button>
@@ -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);
}
@@ -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">&#8249;</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">&#8250;</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>
@@ -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);
}
@@ -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">&#8249;</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">&#8250;</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>
@@ -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);
}
@@ -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>
@@ -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);
}
@@ -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>
@@ -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);
}
@@ -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>
@@ -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);
}
+3 -1
View File
@@ -1,4 +1,6 @@
<div class="greeting"> <div id="Greeting-$$GreetingId$$" class="greeting">
<h1>Hello, $$User$$!</h1> <h1>Hello, $$User$$!</h1>
<p>Welcome to high-performance htmx rendering.</p> <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> </div>
+16 -10
View File
@@ -1,32 +1,38 @@
using Immediate.Apis.Shared;
using Immediate.Handlers.Shared;
namespace Htmx.ApiDemo.Templates; namespace Htmx.ApiDemo.Templates;
public sealed class Greeting : GreetingBase public sealed class Greeting : GreetingBase
{ {
private byte[] _userData = []; private byte[] _userData = [];
private byte[] _countData = [];
private byte[] _greetingIdData = [];
public required string Username { init => _userData = value.ToUtf8Bytes(); } 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); protected override void RenderUser(HtmxRenderContext context) => context.Writer.WriteUtf8(_userData);
} }
[Handler] [Handler]
[MapGet("/greet/{username}")] [MapGet("/greet/{username}/{count?}/{id?}")]
public static partial class GetGreetingHandler public static partial class GetGreetingHandler
{ {
public record Query(string Username); public record Query(string Username, int? Count, Guid? Id);
private static ValueTask HandleAsync( private static ValueTask HandleAsync(
Query query, Query query,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
CancellationToken token) CancellationToken token)
{ {
var context = httpContextAccessor.HttpContext; var context = httpContextAccessor.HttpContext
if(context is null) ?? throw new InvalidOperationException("HttpContext is not available.");
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 template = new Greeting { Username = query.Username, Count = query.Count + 1 ?? 0, GreetingId = query.Id ?? Guid.NewGuid() };
context.WriteHtmxBody(template);
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
} }
+45
View File
@@ -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>
+88
View File
@@ -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");
}
}
+28
View File
@@ -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");
}
}
+116
View File
@@ -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$$</title>
<link href="/css/output.css" rel="stylesheet">
</head>
<body class="bg-background text-foreground antialiased">
<!-- Overlay for mobile sidebar -->
<div id="sidebar-overlay"
class="fixed inset-0 z-20 bg-black/50 opacity-0 pointer-events-none transition-opacity duration-300"
_="on click remove .open from #sidebar
then add .opacity-0 to me
then add .pointer-events-none to me"></div>
<div id="layout-container" class="flex min-h-dvh">
<!-- ── Sidebar ── -->
<aside id="sidebar"
class="fixed inset-y-0 left-0 z-30 flex w-64 -translate-x-full flex-col border-r border-border bg-card shadow-lg
transition-transform duration-300 ease-in-out
[&.open]:translate-x-0
md:relative md:translate-x-0 md:shadow-none">
<!-- Sidebar header -->
<div class="flex h-16 items-center gap-3 border-b border-border px-5">
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">A</span>
<span class="text-base font-semibold tracking-tight">$$AppName$$</span>
</div>
<!-- Nav items -->
<nav class="flex-1 overflow-y-auto px-3 py-4 space-y-1">
<a href="/"
hx-get="/" hx-target="#main-view" hx-push-url="true"
class="sidebar-link group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium
text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
<svg class="h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 9.75L12 3l9 6.75V21a.75.75 0 01-.75.75H15v-6H9v6H3.75A.75.75 0 013 21V9.75z"/>
</svg>
Home
</a>
<a href="/ui-demo"
hx-get="/ui-demo" hx-target="#main-view" hx-push-url="true"
class="sidebar-link group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium
text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
<svg class="h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/>
</svg>
UI Demo
</a>
</nav>
<!-- Sidebar footer -->
<div class="border-t border-border px-5 py-3 text-xs text-muted-foreground">
© 2026 $$AppName$$
</div>
</aside>
<!-- ── /Sidebar ── -->
<!-- ── Main area ── -->
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Top navbar -->
<header class="flex h-16 shrink-0 items-center gap-4 border-b border-border bg-card/80 px-4 backdrop-blur md:px-6">
<!-- Mobile hamburger -->
<button class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-input
bg-transparent text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:hidden"
aria-label="Toggle sidebar"
_="on click toggle .open on #sidebar
then toggle .opacity-0 on #sidebar-overlay
then toggle .pointer-events-none on #sidebar-overlay">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
<!-- Breadcrumb / title -->
<div class="flex-1 text-sm font-medium text-foreground">$$PageTitle$$</div>
<!-- Right-side actions -->
<div class="flex items-center gap-2">
<!-- Theme toggle -->
<button class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-input
bg-transparent transition-colors hover:bg-accent hover:text-accent-foreground
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Toggle theme"
_="on click toggle .dark on <html/>">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 7a5 5 0 100 10A5 5 0 0012 7z"/>
</svg>
</button>
$$UserSection$$
</div>
</header>
<!-- Page content -->
<main id="main-view" class="flex-1 overflow-y-auto p-6">
$$Body$$
</main>
</div>
<!-- ── /Main area ── -->
</div>
<script src="https://cdn.jsdelivr.net/npm/hyperscript.org@0.9.91/dist/_hyperscript.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"></script>
<script src="/js/components.js"></script>
</body>
</html>
+91
View File
@@ -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)
? ""
: $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afToken)}" />""";
_userSectionData = userName is not null
? $"""
<div class="flex items-center gap-2">
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-semibold select-none" title="{System.Web.HttpUtility.HtmlAttributeEncode(userName)}">
{System.Web.HttpUtility.HtmlEncode(GetInitials(userName))}
</div>
<form method="post" action="/logout">
{afInput}
<button type="submit"
class="inline-flex h-8 items-center rounded-md border border-input bg-transparent px-3 text-xs
font-medium transition-colors hover:bg-accent hover:text-accent-foreground
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
Sign out
</button>
</form>
</div>
""".ToUtf8Bytes()
: """
<a href="/login"
class="inline-flex h-8 items-center rounded-md border border-input bg-transparent px-3 text-xs
font-medium transition-colors hover:bg-accent hover:text-accent-foreground
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
Sign in
</a>
""".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;
}
}
+63
View File
@@ -0,0 +1,63 @@
<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">Create an account</h1>
<p class="mt-1 text-sm text-muted-foreground">Fill in the details below to get started</p>
</div>
$$ErrorMessage$$
<form method="post" action="/register" class="space-y-4">
$$AntiforgeryToken$$
<div class="space-y-2">
<label class="text-sm font-medium leading-none text-foreground" for="reg-displayname">Display name</label>
<input id="reg-displayname" name="displayName" type="text" autocomplete="name"
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="Your name" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium leading-none text-foreground" for="reg-email">Email</label>
<input id="reg-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="reg-password">Password</label>
<input id="reg-password" name="password" type="password" required autocomplete="new-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="Min. 6 characters" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium leading-none text-foreground" for="reg-confirm">Confirm password</label>
<input id="reg-confirm" name="confirmPassword" type="password" required autocomplete="new-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">
Create account
</button>
</form>
<p class="text-center text-sm text-muted-foreground">
Already have an account?
<a href="/login" class="font-medium text-primary hover:underline">Sign in</a>
</p>
</div>
</div>
+99
View File
@@ -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)
? []
: $"""<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("/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");
}
}
+68
View File
@@ -0,0 +1,68 @@
<div class="space-y-10">
<!-- ── Buttons ── -->
<section>
<h2 class="mb-4 text-lg font-semibold text-foreground">Buttons</h2>
<div class="flex flex-wrap gap-3">
$$BtnDefault$$
$$BtnDestructive$$
$$BtnOutline$$
$$BtnSecondary$$
$$BtnGhost$$
$$BtnLink$$
$$BtnSm$$
$$BtnLg$$
</div>
</section>
<hr class="border-border" />
<!-- ── Inputs ── -->
<section>
<h2 class="mb-4 text-lg font-semibold text-foreground">Inputs</h2>
<div class="grid max-w-xl grid-cols-1 gap-4 sm:grid-cols-2">
$$InputText$$
$$InputEmail$$
$$InputPassword$$
$$InputSearch$$
</div>
</section>
<hr class="border-border" />
<!-- ── Select ── -->
<section>
<h2 class="mb-4 text-lg font-semibold text-foreground">Select</h2>
<div class="max-w-xs">
$$SelectDemo$$
</div>
</section>
<hr class="border-border" />
<!-- ── Calendar ── -->
<section>
<h2 class="mb-4 text-lg font-semibold text-foreground">Calendar</h2>
$$CalendarDemo$$
</section>
<hr class="border-border" />
<!-- ── Calendar Range ── -->
<section>
<h2 class="mb-4 text-lg font-semibold text-foreground">Calendar Range</h2>
$$CalendarRangeDemo$$
</section>
<hr class="border-border" />
<!-- ── Time Picker ── -->
<section>
<h2 class="mb-4 text-lg font-semibold text-foreground">Time Picker</h2>
<div class="flex flex-wrap gap-8">
$$TimePickerDemo$$
$$TimePicker12hDemo$$
</div>
</section>
</div>
+101
View File
@@ -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;
}
}
+4
View File
@@ -1,4 +1,8 @@
{ {
"ConnectionStrings": {
"DefaultConnection": "mongodb://localhost:27017"
},
"MongoDbName": "HtmxAppDb",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
+6
View File
@@ -0,0 +1,6 @@
{
"dependencies": {
"@tailwindcss/cli": "^4.2.4",
"tailwindcss": "^4.2.4"
}
}
+187
View File
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
+458
View File
@@ -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);
})();
+11 -1
View File
@@ -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] [Generator]
public class HtmxGenerator : IIncrementalGenerator public class HtmxGenerator : IIncrementalGenerator