Added components, authentication and authorization
This commit is contained in:
+3
-1
@@ -1,2 +1,4 @@
|
|||||||
bin
|
bin
|
||||||
obj
|
obj
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">‹</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>
|
||||||
@@ -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">‹</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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "mongodb://localhost:27017"
|
||||||
|
},
|
||||||
|
"MongoDbName": "HtmxAppDb",
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/cli": "^4.2.4",
|
||||||
|
"tailwindcss": "^4.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
})();
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user