Added components, authentication and authorization
This commit is contained in:
@@ -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>
|
||||
<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>
|
||||
@@ -1,32 +1,38 @@
|
||||
using Immediate.Apis.Shared;
|
||||
using Immediate.Handlers.Shared;
|
||||
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class Greeting : GreetingBase
|
||||
{
|
||||
private byte[] _userData = [];
|
||||
private byte[] _countData = [];
|
||||
private byte[] _greetingIdData = [];
|
||||
public required string Username { init => _userData = value.ToUtf8Bytes(); }
|
||||
public required int Count { init => _countData = $"{value}".ToUtf8Bytes(); }
|
||||
public required Guid GreetingId { init => _greetingIdData = $"{value}".ToUtf8Bytes(); }
|
||||
|
||||
protected override void RenderCount(HtmxRenderContext context) => context.Writer.WriteUtf8(_countData);
|
||||
protected override void RenderGreetingId(HtmxRenderContext context) => context.Writer.WriteUtf8(_greetingIdData);
|
||||
protected override void RenderUser(HtmxRenderContext context) => context.Writer.WriteUtf8(_userData);
|
||||
}
|
||||
|
||||
[Handler]
|
||||
[MapGet("/greet/{username}")]
|
||||
[MapGet("/greet/{username}/{count?}/{id?}")]
|
||||
public static partial class GetGreetingHandler
|
||||
{
|
||||
public record Query(string Username);
|
||||
public record Query(string Username, int? Count, Guid? Id);
|
||||
|
||||
private static ValueTask HandleAsync(
|
||||
Query query,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
CancellationToken token)
|
||||
{
|
||||
var context = httpContextAccessor.HttpContext;
|
||||
if(context is null)
|
||||
throw new InvalidOperationException("HttpContext is not available.");
|
||||
|
||||
var template = new Greeting { Username = query.Username };
|
||||
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
context.Response.BodyWriter.WriteHtmx(template);
|
||||
var context = httpContextAccessor.HttpContext
|
||||
?? throw new InvalidOperationException("HttpContext is not available.");
|
||||
|
||||
var template = new Greeting { Username = query.Username, Count = query.Count + 1 ?? 0, GreetingId = query.Id ?? Guid.NewGuid() };
|
||||
context.WriteHtmxBody(template);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user