Попередні дві статті заклали фундамент: атрибути HTMX, серверна інтеграція з MVC, патерни live-search, lazy loading та inline edit. Тепер настав час об'єднати весь цей арсенал в єдиний живий застосунок.
ProductHub — це повнофункціональний каталог товарів, де кожна інтерактивна функція реалізована через HTMX без жодного JavaScript-фреймворку. Цей проєкт є синтезом усього вивченого в статтях 12–13, представленим у вигляді покрокової інструкції від dotnet new до завершеного застосунку. Кожен крок є само-достатнім: ви виконуєте його — і бачите результат у браузері.
Застосунок складатиметься з двох розділів: публічного каталогу та адмін-панелі. Кожен розділ демонструє окремий набір HTMX-можливостей:
Каталог товарів
hx-indicator-спіннером. Фільтрація по категоріям через бокову панель з активним станом. Infinite scroll з sentinel-елементом (hx-trigger="revealed"). Картки товарів з кнопкою «Додати до кошика».Кошик та сповіщення
HX-Trigger: cartUpdated. Toast-сповіщення через OOB swaps при додаванні товару. Сторінка кошика з видаленням через hx-delete.Адмін-панель
Технологічний стек:
dotnet new mvc -n ProductHub --framework net9.0
cd ProductHub
Відкрийте Views/Shared/_Layout.cshtml і додайте у <head>:
@* Bootstrap (вже є у шаблоні) *@
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css">
@* Bootstrap Icons *@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
@* Наші стилі *@
<link rel="stylesheet" href="~/css/site.css">
@* AntiForgery meta-тег для HTMX-кнопок поза формами *@
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
<meta name="RequestVerificationToken"
content="@Antiforgery.GetAndStoreTokens(Context).RequestToken">
Перед закриваючим </body> замінити Bootstrap JS на:
@* HTMX *@
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
@* Єдиний JS-файл: AntiForgery + глобальна обробка помилок *@
<script src="~/js/htmx-setup.js"></script>
@* Bootstrap JS (для Modal) *@
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddAntiforgery(options =>
options.HeaderName = "RequestVerificationToken");
// Реєстрація репозиторію (наступний крок)
builder.Services.AddSingleton<IProductRepository, InMemoryProductRepository>();
builder.Services.AddSingleton<ICartRepository, InMemoryCartRepository>();
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.Run();
namespace ProductHub.Models;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Category { get; set; } = "";
public decimal Price { get; set; }
public string Description { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public record ProductUpdateDto(string Name, decimal Price);
public record ProductCreateDto(string Name, string Category, decimal Price, string Description);
using ProductHub.Models;
namespace ProductHub.Repositories;
public interface IProductRepository
{
IReadOnlyList<Product> GetAll();
IReadOnlyList<Product> Search(string? q, string? category);
IReadOnlyList<Product> GetPage(int page, int pageSize, string? category = null);
bool HasMore(int page, int pageSize, string? category = null);
Product? GetById(int id);
Product Add(ProductCreateDto dto);
Product? Update(int id, ProductUpdateDto dto);
bool Delete(int id);
IReadOnlyList<string> GetCategories();
}
public class InMemoryProductRepository : IProductRepository
{
private static readonly List<Product> _products =
[
new() { Id=1, Name="MacBook Pro 14\"", Category="Ноутбуки", Price=89999, Description="Apple M4 Pro" },
new() { Id=2, Name="ASUS ROG Strix G15", Category="Ноутбуки", Price=54999, Description="Ігровий ноутбук" },
new() { Id=3, Name="iPhone 16 Pro", Category="Смартфони", Price=49999, Description="Флагман Apple" },
new() { Id=4, Name="Samsung Galaxy S25", Category="Смартфони", Price=39999, Description="Android флагман" },
new() { Id=5, Name="Sony WH-1000XM5", Category="Навушники", Price=12999, Description="ANC навушники" },
new() { Id=6, Name="Apple AirPods Pro", Category="Навушники", Price=10999, Description="True wireless" },
new() { Id=7, Name="iPad Pro 12.9\"", Category="Планшети", Price=44999, Description="Chip M4" },
new() { Id=8, Name="Samsung Galaxy Tab S9", Category="Планшети", Price=29999, Description="Android планшет" },
new() { Id=9, Name="Logitech MX Master 3S", Category="Аксесуари", Price=4999, Description="Бездротова миша" },
new() { Id=10, Name="Keychron K2 Pro", Category="Аксесуари", Price=5999, Description="Механічна клавіатура" },
new() { Id=11, Name="LG 27GP850-B", Category="Монітори", Price=18999, Description="IPS 165Hz" },
new() { Id=12, Name="Samsung Odyssey G7", Category="Монітори", Price=21999, Description="QLED 240Hz" },
];
private static int _nextId = 13;
private static readonly object _lock = new();
public IReadOnlyList<Product> GetAll() => _products.AsReadOnly();
public IReadOnlyList<Product> Search(string? q, string? category)
{
var query = _products.AsQueryable();
if (!string.IsNullOrWhiteSpace(category))
query = query.Where(p => p.Category == category);
if (!string.IsNullOrWhiteSpace(q))
query = query.Where(p =>
p.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
p.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
return query.ToList().AsReadOnly();
}
public IReadOnlyList<Product> GetPage(int page, int pageSize, string? category = null)
{
var items = string.IsNullOrWhiteSpace(category)
? _products
: _products.Where(p => p.Category == category).ToList();
return items.Skip((page - 1) * pageSize).Take(pageSize).ToList().AsReadOnly();
}
public bool HasMore(int page, int pageSize, string? category = null)
{
var total = string.IsNullOrWhiteSpace(category)
? _products.Count
: _products.Count(p => p.Category == category);
return page * pageSize < total;
}
public Product? GetById(int id) => _products.FirstOrDefault(p => p.Id == id);
public Product Add(ProductCreateDto dto)
{
lock (_lock)
{
var product = new Product
{
Id = _nextId++, Name = dto.Name, Category = dto.Category,
Price = dto.Price, Description = dto.Description
};
_products.Add(product);
return product;
}
}
public Product? Update(int id, ProductUpdateDto dto)
{
lock (_lock)
{
var product = _products.FirstOrDefault(p => p.Id == id);
if (product is null) return null;
product.Name = dto.Name;
product.Price = dto.Price;
return product;
}
}
public bool Delete(int id)
{
lock (_lock) { return _products.RemoveAll(p => p.Id == id) > 0; }
}
public IReadOnlyList<string> GetCategories()
=> _products.Select(p => p.Category).Distinct().OrderBy(c => c).ToList().AsReadOnly();
}
lock(_lock) забезпечує потокобезпеку при паралельних запитах до статичного списку в AddSingleton-сервісі.
// AntiForgery: передавати токен у заголовку для POST/PUT/PATCH/DELETE
document.addEventListener("htmx:configRequest", (event) => {
const method = event.detail.verb.toUpperCase();
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
const token = document.querySelector('meta[name="RequestVerificationToken"]')
?.getAttribute("content");
if (token) event.detail.headers["RequestVerificationToken"] = token;
}
});
// Глобальна обробка HTTP-помилок
document.addEventListener("htmx:responseError", (event) => {
const status = event.detail.xhr.status;
if (status === 403) alert("Доступ заборонено");
else if (status >= 500) alert("Помилка сервера. Спробуйте пізніше.");
});
using Microsoft.AspNetCore.Mvc;
using ProductHub.Models;
using ProductHub.Repositories;
namespace ProductHub.Controllers;
public class ProductController : Controller
{
private readonly IProductRepository _products;
private const int PageSize = 4; // невелике число для демонстрації infinite scroll
public ProductController(IProductRepository products) => _products = products;
// GET /product — повна сторінка каталогу
public IActionResult Index(string? category)
{
var page = _products.GetPage(1, PageSize, category);
var hasMore = _products.HasMore(1, PageSize, category);
var categories = _products.GetCategories();
ViewBag.Categories = categories;
ViewBag.ActiveCategory = category;
return View((page, hasMore));
}
// GET /product/search?q=ноутбук&category=Ноутбуки — HTMX live-search
[HttpGet]
public IActionResult Search(string? q, string? category)
{
var results = _products.Search(q, category);
return PartialView("_ProductGrid", results);
}
// GET /product/page?page=2&category=Смартфони — HTMX infinite scroll
[HttpGet]
public IActionResult Page(int page, string? category)
{
var items = _products.GetPage(page, PageSize, category);
var hasMore = _products.HasMore(page, PageSize, category);
return PartialView("_ProductPage", (items, page, hasMore, category));
}
}
@model (IReadOnlyList<ProductHub.Models.Product> Page, bool HasMore)
@{ ViewData["Title"] = "Каталог товарів"; var categories = ViewBag.Categories as IReadOnlyList<string>; }
<div class="container-fluid mt-3">
<div class="row">
@* === Бокова панель фільтрів === *@
<aside class="col-md-2">
<h6 class="text-uppercase text-muted fw-bold small mb-3">Категорії</h6>
<div class="d-grid gap-1">
@* Кнопка "Всі" *@
<button class="btn @(ViewBag.ActiveCategory == null ? "btn-primary" : "btn-outline-secondary") btn-sm text-start"
hx-get="/product/search"
hx-target="#product-container"
hx-swap="innerHTML"
hx-include="#search-input">
Всі категорії
</button>
@* Кнопки категорій *@
@foreach (var cat in categories!)
{
<button class="btn @(ViewBag.ActiveCategory == cat ? "btn-primary" : "btn-outline-secondary") btn-sm text-start"
hx-get="/product/search?category=@Uri.EscapeDataString(cat)"
hx-target="#product-container"
hx-swap="innerHTML"
hx-include="#search-input">
@cat
</button>
}
</div>
</aside>
@* === Основна зона контенту === *@
<main class="col-md-10">
@* Поле пошуку з debounce *@
<div class="mb-4 position-relative">
<input id="search-input" type="search" name="q"
class="form-control form-control-lg shadow-sm"
placeholder="🔍 Пошук товарів..."
hx-get="/product/search"
hx-target="#product-container"
hx-trigger="input changed delay:300ms"
hx-indicator="#search-spinner"
autocomplete="off">
<div id="search-spinner" class="htmx-indicator position-absolute top-50 end-0 translate-middle-y me-3">
<div class="spinner-border spinner-border-sm text-secondary"></div>
</div>
</div>
@* Контейнер результатів — infinite scroll всередині *@
<div id="product-container">
@* Перша порція карток *@
<div class="row row-cols-1 row-cols-md-4 g-4 mb-3">
@foreach (var p in Model.Page)
{
<partial name="_ProductCard" model="p"/>
}
</div>
@* Sentinel для infinite scroll *@
@if (Model.HasMore)
{
<div hx-get="/product/page?page=2"
hx-trigger="revealed"
hx-swap="outerHTML"
class="d-flex justify-content-center py-3">
<div class="spinner-border text-primary"></div>
</div>
}
</div>
</main>
</div>
</div>
@model ProductHub.Models.Product
<div class="col">
<div class="card h-100 shadow-sm border-0">
<div class="card-body d-flex flex-column">
<h5 class="card-title fw-semibold">@Model.Name</h5>
<p class="text-muted small mb-1">@Model.Category</p>
<p class="card-text text-muted small flex-grow-1">@Model.Description</p>
<div class="d-flex align-items-center justify-content-between mt-3">
<span class="fs-5 fw-bold text-primary">@Model.Price.ToString("N0") ₴</span>
<button class="btn btn-sm btn-outline-primary"
hx-post="/cart/add/@Model.Id"
hx-swap="none"
title="Додати до кошика">
<i class="bi bi-cart-plus"></i>
</button>
</div>
</div>
</div>
</div>
@model IReadOnlyList<ProductHub.Models.Product>
@if (!Model.Any())
{
<div class="alert alert-info rounded-3">
<i class="bi bi-search me-2"></i>За вашим запитом нічого не знайдено.
</div>
}
else
{
<div class="row row-cols-1 row-cols-md-4 g-4">
@foreach (var p in Model)
{
<partial name="_ProductCard" model="p"/>
}
</div>
}
@model (IReadOnlyList<ProductHub.Models.Product> Items, int Page, bool HasMore, string? Category)
@* Картки нової порції *@
<div class="row row-cols-1 row-cols-md-4 g-4 mb-3">
@foreach (var p in Model.Items)
{
<partial name="_ProductCard" model="p"/>
}
</div>
@* Наступний sentinel або кінець списку *@
@if (Model.HasMore)
{
var nextPage = Model.Page + 1;
var catParam = Model.Category != null ? $"&category={Uri.EscapeDataString(Model.Category)}" : "";
<div hx-get="/product/page?page=@nextPage@catParam"
hx-trigger="revealed"
hx-swap="outerHTML"
class="d-flex justify-content-center py-3">
<div class="spinner-border text-primary"></div>
</div>
}
else
{
<p class="text-center text-muted py-3 small">Всі товари завантажено ✓</p>
}
namespace ProductHub.Repositories;
public interface ICartRepository
{
void AddItem(int productId, string productName, decimal price);
void RemoveItem(int productId);
IReadOnlyDictionary<int, (string Name, decimal Price, int Qty)> GetItems();
int GetCount();
}
public class InMemoryCartRepository : ICartRepository
{
// Session-агностичний in-memory кошик для демонстрації
private static readonly Dictionary<int, (string Name, decimal Price, int Qty)> _items = new();
private static readonly object _lock = new();
public void AddItem(int productId, string productName, decimal price)
{
lock (_lock)
{
if (_items.TryGetValue(productId, out var existing))
_items[productId] = (existing.Name, existing.Price, existing.Qty + 1);
else
_items[productId] = (productName, price, 1);
}
}
public void RemoveItem(int productId) { lock (_lock) { _items.Remove(productId); } }
public IReadOnlyDictionary<int, (string Name, decimal Price, int Qty)> GetItems() => _items;
public int GetCount() => _items.Values.Sum(i => i.Qty);
}
using Microsoft.AspNetCore.Mvc;
using ProductHub.Repositories;
namespace ProductHub.Controllers;
public class CartController : Controller
{
private readonly ICartRepository _cart;
private readonly IProductRepository _products;
public CartController(ICartRepository cart, IProductRepository products)
{ _cart = cart; _products = products; }
// POST /cart/add/{productId} — додати товар; відповідь з OOB swaps
[HttpPost, ValidateAntiForgeryToken]
public IActionResult Add(int productId)
{
var product = _products.GetById(productId);
if (product is null) return NotFound();
_cart.AddItem(product.Id, product.Name, product.Price);
// HX-Trigger: сигналізує navbar-лічильнику оновитися
Response.Headers["HX-Trigger"] = "cartUpdated";
// hx-swap="none" на кнопці → тіло відповіді ігнорується,
// але OOB-фрагменти у відповіді все одно обробляються
return PartialView("_CartOob", (product.Name, _cart.GetCount()));
}
// DELETE /cart/remove/{productId}
[HttpDelete, ValidateAntiForgeryToken]
public IActionResult Remove(int productId)
{
_cart.RemoveItem(productId);
Response.Headers["HX-Trigger"] = "cartUpdated";
return Ok();
}
public IActionResult Index()
{
var items = _cart.GetItems();
return View(items);
}
}
@model (string ProductName, int Count)
@* OOB #1: Toast-сповіщення — з'являється у #toast-container у _Layout *@
<div id="toast-container" hx-swap-oob="innerHTML">
<div class="toast show align-items-center text-bg-success border-0 position-fixed
bottom-0 end-0 m-4" role="alert" style="z-index:9999"
hx-on::load="setTimeout(()=>this.classList.remove('show'),3000)">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-check-circle me-1"></i>
«@Model.ProductName» додано до кошика!
</div>
</div>
</div>
</div>
@* OOB #2: лічильник у навбарі *@
<span id="cart-count" hx-swap-oob="innerHTML">@Model.Count</span>
Додайте у navbar:
<a class="nav-link position-relative" asp-controller="Cart" asp-action="Index">
<i class="bi bi-cart3 fs-5"></i>
<span id="cart-count" class="position-absolute top-0 start-100 translate-middle
badge rounded-pill bg-danger">0</span>
</a>
@* Toast-контейнер — пустий при завантаженні *@
<div id="toast-container"></div>
hx-swap="none" на кнопці кошика означає: основна відповідь ігнорується — DOM не змінюється. Але HTMX все одно парсить тіло відповіді на наявність hx-swap-oob елементів і обробляє їх. Це дає можливість оновити toast і лічильник без hx-target.using Microsoft.AspNetCore.Mvc;
using ProductHub.Models;
using ProductHub.Repositories;
namespace ProductHub.Controllers.Admin;
[Route("admin/products")]
public class ProductController : Controller
{
private readonly IProductRepository _products;
public ProductController(IProductRepository products) => _products = products;
[HttpGet("")] public IActionResult Index() => View(_products.GetAll());
// Три стани рядка: view / edit / update
[HttpGet("{id}/row")]
public IActionResult Row(int id) => PartialView("_ProductRow", _products.GetById(id));
[HttpGet("{id}/edit-row")]
public IActionResult EditRow(int id) => PartialView("_ProductEditRow", _products.GetById(id));
[HttpPatch("{id}"), ValidateAntiForgeryToken]
public IActionResult Update(int id, ProductUpdateDto dto)
{
if (!ModelState.IsValid) { Response.StatusCode = 422; return PartialView("_ProductEditRow", _products.GetById(id)); }
var updated = _products.Update(id, dto);
return updated is null ? NotFound() : PartialView("_ProductRow", updated);
}
// Модальне вікно створення
[HttpGet("create-modal")]
public IActionResult CreateModal() => PartialView("_CreateModal");
[HttpPost(""), ValidateAntiForgeryToken]
public IActionResult Create(ProductCreateDto dto)
{
if (!ModelState.IsValid) { Response.StatusCode = 422; return PartialView("_CreateModal", dto); }
var product = _products.Add(dto);
Response.Headers["HX-Trigger"] = "productCreated";
return PartialView("_ProductRow", product);
}
[HttpDelete("{id}"), ValidateAntiForgeryToken]
public IActionResult Delete(int id)
{
_products.Delete(id);
return Ok();
}
}
@model IReadOnlyList<ProductHub.Models.Product>
@{ ViewData["Title"] = "Адмін: Товари"; }
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Управління товарами</h1>
@* Кнопка відкриває модаль через HTMX *@
<button class="btn btn-primary"
hx-get="/admin/products/create-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
data-bs-toggle="modal"
data-bs-target="#product-modal">
<i class="bi bi-plus-lg me-1"></i>Додати товар
</button>
</div>
<table class="table table-hover">
<thead class="table-dark">
<tr><th>Назва</th><th>Категорія</th><th>Ціна</th><th>Дії</th></tr>
</thead>
<tbody id="products-tbody"
hx-trigger="productCreated from:body"
hx-get="/admin/products"
hx-swap="innerHTML"
hx-select="tbody#products-tbody">
@foreach (var p in Model)
{
<partial name="_ProductRow" model="p"/>
}
</tbody>
</table>
</div>
@* Bootstrap Modal — порожній; HTMX заповнить через hx-get *@
<div class="modal fade" id="product-modal" tabindex="-1">
<div class="modal-dialog">
<div id="modal-container"></div>
</div>
</div>
@model ProductHub.Models.Product
<tr id="row-@Model.Id">
<td>@Model.Name</td>
<td><span class="badge bg-secondary">@Model.Category</span></td>
<td class="fw-bold">@Model.Price.ToString("N0") ₴</td>
<td>
<button class="btn btn-sm btn-outline-primary me-1"
hx-get="/admin/products/@Model.Id/edit-row"
hx-target="#row-@Model.Id" hx-swap="outerHTML">✏️</button>
<button class="btn btn-sm btn-outline-danger"
hx-delete="/admin/products/@Model.Id"
hx-target="#row-@Model.Id" hx-swap="outerHTML"
hx-confirm="Видалити «@Model.Name»?">🗑️</button>
</td>
</tr>
@model ProductHub.Models.Product
<tr id="row-@Model.Id">
<td><input name="Name" value="@Model.Name" class="form-control form-control-sm" required></td>
<td><span class="badge bg-secondary">@Model.Category</span></td>
<td><input name="Price" value="@Model.Price" type="number" step="0.01"
class="form-control form-control-sm" style="width:120px" required></td>
<td>
<button class="btn btn-sm btn-success me-1"
hx-patch="/admin/products/@Model.Id"
hx-target="#row-@Model.Id" hx-swap="outerHTML"
hx-include="closest tr">✓</button>
<button class="btn btn-sm btn-outline-secondary"
hx-get="/admin/products/@Model.Id/row"
hx-target="#row-@Model.Id" hx-swap="outerHTML">✕</button>
</td>
</tr>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Новий товар</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form hx-post="/admin/products"
hx-target="tbody#products-tbody"
hx-swap="beforeend"
hx-on::after-request="if(event.detail.successful) bootstrap.Modal.getInstance(document.getElementById('product-modal')).hide()">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Назва</label>
<input name="Name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Категорія</label>
<input name="Category" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Ціна (₴)</label>
<input name="Price" type="number" step="0.01" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Опис</label>
<textarea name="Description" class="form-control" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Скасувати</button>
<button type="submit" class="btn btn-primary">
<span class="htmx-indicator spinner-border spinner-border-sm me-1"></span>
Зберегти
</button>
</div>
</form>
</div>
hx-on::after-request закриває модаль Bootstrap після успішного збереження. HX-Trigger: productCreated з сервера змушує <tbody> перезавантажитись — новий рядок з'являється у таблиці і без beforeend, гарантуючи актуальність даних.
Завдання 1.1. Додайте у каталог сортування: кнопки «За ціною ↑» та «За ціною ↓» у боковій панелі. Кожна кнопка включає поточний пошуковий запит через hx-include="#search-input". Rozширте IProductRepository.Search() параметром string? sort.
Завдання 1.2. Додайте на сторінку кошика (Views/Cart/Index.cshtml) кнопку «Видалити» для кожного товару через hx-delete="/cart/remove/{id}". Після видалення рядок зникає (hx-swap="outerHTML"), а лічильник у навбарі оновлюється через HX-Trigger: cartUpdated.
Завдання 2.1. Реалізуйте «Кількість» у кошику: кнопки + і − для зміни кількості через hx-patch="/cart/update/{id}?qty=2". Рядок замінюється оновленим фрагментом. Загальна сума рядка і підсумок внизу перераховуються через OOB swaps.
Завдання 2.2. Додайте до адмін-панелі live-search по таблиці товарів: <input hx-get="/admin/products/search" hx-target="tbody#products-tbody" hx-trigger="input changed delay:200ms" name="q">. Поверніть рядки що відповідають запиту.
Завдання 3.1. Замініть in-memory CartRepository на session-based: підключіть AddDistributedMemoryCache() + AddSession() у Program.cs, серіалізуйте кошик у JSON у HttpContext.Session. Кожен браузерний сеанс матиме власний окремий кошик.
Ви побудували повнофункціональний HTMX+MVC застосунок, де кожна інтерактивна функція реалізована декларативно — через HTML-атрибути:
| Функція | HTMX-інструмент |
|---|---|
| Live-search | hx-get + hx-trigger="input changed delay:300ms" + hx-indicator |
| Фільтрація по категоріям | hx-get + hx-include для передачі поточного пошуку |
| Infinite scroll | hx-trigger="revealed" + sentinel з hx-swap="outerHTML" |
| Кошик + лічильник | HX-Trigger: cartUpdated + слухач from:body |
| Toast-сповіщення | OOB swaps hx-swap-oob з _CartOob.cshtml |
| Inline edit | Три PartialView стани + hx-include="closest tr" |
| Модальне вікно | hx-get заповнює Bootstrap Modal + hx-on::after-request закриває |
| AntiForgery | Мета-тег + htmx:configRequest у єдиному htmx-setup.js |
| Підтвердження видалення | hx-confirm — нативний браузерний діалог |
Весь цей функціонал побудований без жодного JavaScript-фреймворку — лише HTMX як клієнтська бібліотека та ~20 рядків htmx-setup.js для налаштування безпеки.
У наступній статті — Завантаження файлів: IFormFile, валідація MIME та розміру, збереження у wwwroot та поза ним, streaming для великих файлів, FileResult та захист завантажень.
HTMX у ASP.NET Core MVC: серверна інтеграція
Інтеграція HTMX з ASP.NET Core MVC: виявлення HTMX-запитів через HX-Request, AntiForgery token, response headers HX-Redirect та HX-Trigger. Практичні патерни: live-search, lazy loading таблиці, inline edit клітинки.
Завантаження та обробка файлів
File Upload в ASP.NET Core MVC: IFormFile та IFormFileCollection, валідація (MIME-тип, розмір, розширення), збереження у wwwroot та поза webroot, streaming великих файлів, FileResult та PhysicalFileResult, захист доступу до файлів. Демо: UserProfileController — аватар, галерея, захищені файли.