Initial commit
This commit is contained in:
32
Groceries/Common/TurboControllerExtensions.cs
Normal file
32
Groceries/Common/TurboControllerExtensions.cs
Normal file
@ -0,0 +1,32 @@
|
||||
namespace Groceries.Common;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
public static class TurboControllerExtensions
|
||||
{
|
||||
public static TurboStreamResult TurboStream(
|
||||
this Controller controller,
|
||||
TurboStreamAction action,
|
||||
string target,
|
||||
object? model)
|
||||
{
|
||||
return controller.TurboStream(action, target, null, model);
|
||||
}
|
||||
|
||||
public static TurboStreamResult TurboStream(
|
||||
this Controller controller,
|
||||
TurboStreamAction action,
|
||||
string target,
|
||||
string? viewName,
|
||||
object? model)
|
||||
{
|
||||
controller.ViewData.Model = model;
|
||||
|
||||
return new TurboStreamResult(action, target)
|
||||
{
|
||||
ViewName = viewName,
|
||||
ViewData = controller.ViewData,
|
||||
TempData = controller.TempData,
|
||||
};
|
||||
}
|
||||
}
|
19
Groceries/Common/TurboHttpRequestExtensions.cs
Normal file
19
Groceries/Common/TurboHttpRequestExtensions.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace Groceries.Common;
|
||||
|
||||
public static class TurboHttpRequestExtensions
|
||||
{
|
||||
public static bool IsTurboFrameRequest(this HttpRequest request)
|
||||
{
|
||||
return request.Headers.ContainsKey("Turbo-Frame");
|
||||
}
|
||||
|
||||
public static bool IsTurboFrameRequest(this HttpRequest request, string frameId)
|
||||
{
|
||||
return request.Headers.TryGetValue("Turbo-Frame", out var values) && values.Contains(frameId);
|
||||
}
|
||||
|
||||
public static bool AcceptsTurboStream(this HttpRequest request)
|
||||
{
|
||||
return request.GetTypedHeaders().Accept.Any(value => value.MediaType == "text/vnd.turbo-stream.html");
|
||||
}
|
||||
}
|
182
Groceries/Common/TurboStreamResult.cs
Normal file
182
Groceries/Common/TurboStreamResult.cs
Normal file
@ -0,0 +1,182 @@
|
||||
namespace Groceries.Common;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
|
||||
public enum TurboStreamAction
|
||||
{
|
||||
Append,
|
||||
Prepend,
|
||||
Replace,
|
||||
Update,
|
||||
Remove,
|
||||
Before,
|
||||
After,
|
||||
}
|
||||
|
||||
public class TurboStreamResult : ActionResult, IStatusCodeActionResult
|
||||
{
|
||||
public TurboStreamResult(TurboStreamAction action, string target)
|
||||
{
|
||||
Action = action;
|
||||
Target = target;
|
||||
}
|
||||
|
||||
public TurboStreamAction Action { get; set; }
|
||||
|
||||
public string Target { get; set; }
|
||||
|
||||
public string ContentType => "text/vnd.turbo-stream.html";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the HTTP status code.
|
||||
/// </summary>
|
||||
public int? StatusCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name or path of the partial view that is rendered to the response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <c>null</c>, defaults to <see cref="ControllerActionDescriptor.ActionName"/>.
|
||||
/// </remarks>
|
||||
public string? ViewName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the view data model.
|
||||
/// </summary>
|
||||
public object? Model => ViewData.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="ViewDataDictionary"/> for this result.
|
||||
/// </summary>
|
||||
public ViewDataDictionary ViewData { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="ITempDataDictionary"/> for this result.
|
||||
/// </summary>
|
||||
public ITempDataDictionary TempData { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="IViewEngine"/> used to locate views.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <c>null</c>, an instance of <see cref="ICompositeViewEngine"/> from
|
||||
/// <c>ActionContext.HttpContext.RequestServices</c> is used.
|
||||
/// </remarks>
|
||||
public IViewEngine? ViewEngine { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task ExecuteResultAsync(ActionContext context)
|
||||
{
|
||||
var services = context.HttpContext.RequestServices;
|
||||
var executor = services.GetRequiredService<IActionResultExecutor<TurboStreamResult>>();
|
||||
return executor.ExecuteAsync(context, this);
|
||||
}
|
||||
}
|
||||
|
||||
public class TurboStreamResultExecutor : PartialViewResultExecutor, IActionResultExecutor<TurboStreamResult>
|
||||
{
|
||||
public TurboStreamResultExecutor(
|
||||
IOptions<MvcViewOptions> viewOptions,
|
||||
IHttpResponseStreamWriterFactory writerFactory,
|
||||
ICompositeViewEngine viewEngine,
|
||||
ITempDataDictionaryFactory tempDataFactory,
|
||||
DiagnosticListener diagnosticListener,
|
||||
ILoggerFactory loggerFactory,
|
||||
IModelMetadataProvider modelMetadataProvider)
|
||||
: base(viewOptions, writerFactory, viewEngine, tempDataFactory, diagnosticListener, loggerFactory, modelMetadataProvider)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ExecuteAsync(ActionContext context, TurboStreamResult result)
|
||||
{
|
||||
var viewEngine = result.ViewEngine ?? ViewEngine;
|
||||
var viewName = result.ViewName ?? GetActionName(context)!;
|
||||
|
||||
var viewEngineResult = viewEngine.GetView(executingFilePath: null, viewPath: viewName, isMainPage: false);
|
||||
var originalViewEngineResult = viewEngineResult;
|
||||
if (!viewEngineResult.Success)
|
||||
{
|
||||
viewEngineResult = viewEngine.FindView(context, viewName, isMainPage: false);
|
||||
}
|
||||
|
||||
viewEngineResult.EnsureSuccessful(originalViewEngineResult.SearchedLocations);
|
||||
|
||||
var action = result.Action.ToString().ToLowerInvariant();
|
||||
var preContent = result.Action switch
|
||||
{
|
||||
TurboStreamAction.Remove => $"<turbo-stream action=\"{action}\">\n",
|
||||
_ => $"<turbo-stream action=\"{action}\" target=\"{result.Target}\">\n<template>\n",
|
||||
};
|
||||
var postContent = result.Action switch
|
||||
{
|
||||
TurboStreamAction.Remove => "</turbo-stream>",
|
||||
_ => "</template>\n</turbo-stream>",
|
||||
};
|
||||
|
||||
result.ViewData["RenderingToTurboStream"] = true;
|
||||
|
||||
using var view = new WrapperView(viewEngineResult.View, preContent, postContent);
|
||||
return ExecuteAsync(context, view, result.ViewData, result.TempData, result.ContentType, result.StatusCode);
|
||||
}
|
||||
|
||||
private static string? GetActionName(ActionContext context)
|
||||
{
|
||||
const string actionNameKey = "action";
|
||||
if (!context.RouteData.Values.TryGetValue(actionNameKey, out var routeValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? normalizedValue = null;
|
||||
if (context.ActionDescriptor.RouteValues.TryGetValue(actionNameKey, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
normalizedValue = value;
|
||||
}
|
||||
|
||||
var stringRouteValue = Convert.ToString(routeValue, CultureInfo.InvariantCulture);
|
||||
if (string.Equals(normalizedValue, stringRouteValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
return stringRouteValue;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WrapperView : IDisposable, IView
|
||||
{
|
||||
private readonly IView innerView;
|
||||
private readonly string preContent;
|
||||
private readonly string postContent;
|
||||
|
||||
public WrapperView(IView innerView, string preContent, string postContent)
|
||||
{
|
||||
this.innerView = innerView;
|
||||
this.preContent = preContent;
|
||||
this.postContent = postContent;
|
||||
}
|
||||
|
||||
public string Path => string.Empty;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
(innerView as IDisposable)?.Dispose();
|
||||
}
|
||||
|
||||
public async Task RenderAsync(ViewContext context)
|
||||
{
|
||||
context.Writer.Write(preContent);
|
||||
await innerView.RenderAsync(context);
|
||||
context.Writer.Write(postContent);
|
||||
}
|
||||
}
|
27
Groceries/Common/_Layout.cshtml
Normal file
27
Groceries/Common/_Layout.cshtml
Normal file
@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Groceries@(ViewBag.Title != null ? Html.Raw($" – {ViewBag.Title}") : "")</title>
|
||||
<link rel="stylesheet" type="text/css" href="/css/main.css" asp-append-version="true" data-turbo-track="reload" />
|
||||
<script type="module" src="/js/main.js" asp-append-version="true" data-turbo-track="reload"></script>
|
||||
<script type="module" src="/lib/hotwired/turbo/dist/turbo.es2017-esm.js"></script>
|
||||
|
||||
@RenderSection("head", required: false)
|
||||
</head>
|
||||
<body>
|
||||
<partial name="_LayoutSidebar" />
|
||||
|
||||
<main class="main-content">
|
||||
@*<turbo-frame id="main" target="_top">*@
|
||||
@RenderBody()
|
||||
@*</turbo-frame>*@
|
||||
</main>
|
||||
|
||||
<dialog class="modal" data-controller="modal" data-action="turbo:frame-load->modal#open turbo:before-cache@document->modal#close popstate@window->modal#close">
|
||||
<turbo-frame id="modal" data-modal-target="frame"></turbo-frame>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
53
Groceries/Common/_LayoutSidebar.cshtml
Normal file
53
Groceries/Common/_LayoutSidebar.cshtml
Normal file
@ -0,0 +1,53 @@
|
||||
@{
|
||||
var controller = ViewContext.RouteData.Values["controller"]?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
<input class="sidebar__toggle" id="sidebarToggle" type="checkbox" role="button" data-turbo-permanent />
|
||||
<label for="sidebarToggle">Menu</label>
|
||||
|
||||
<section class="sidebar">
|
||||
<header class="sidebar__header">
|
||||
Groceries
|
||||
</header>
|
||||
<nav class="sidebar__body">
|
||||
<ul>
|
||||
<li class="sidebar__item @(controller == "Home" ? "sidebar__item--active" : "")">
|
||||
@* dashboard squares icon *@
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 5v2h-4V5h4M9 5v6H5V5h4m10 8v6h-4v-6h4M9 17v2H5v-2h4M21 3h-8v6h8V3zM11 3H3v10h8V3zm10 8h-8v10h8V11zm-10 4H3v6h8v-6z"/></svg>
|
||||
<a class="sidebar__link" asp-controller="Home" asp-action="Index">Dashboard</a>
|
||||
</li>
|
||||
<li class="sidebar__item @(controller == "Lists" ? "sidebar__item--active" : "")">
|
||||
@* receipt long icon *@
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24"><path d="M0,0h24v24H0V0z" fill="none"/><g><path d="M19.5,3.5L18,2l-1.5,1.5L15,2l-1.5,1.5L12,2l-1.5,1.5L9,2L7.5,3.5L6,2v14H3v3c0,1.66,1.34,3,3,3h12c1.66,0,3-1.34,3-3V2 L19.5,3.5z M15,20H6c-0.55,0-1-0.45-1-1v-1h10V20z M19,19c0,0.55-0.45,1-1,1s-1-0.45-1-1v-3H8V5h11V19z"/><rect height="2" width="6" x="9" y="7"/><rect height="2" width="2" x="16" y="7"/><rect height="2" width="6" x="9" y="10"/><rect height="2" width="2" x="16" y="10"/></g></svg>
|
||||
<a class="sidebar__link" asp-controller="Lists" asp-action="Index" asp-route-page="1">Lists</a>
|
||||
</li>
|
||||
<li class="sidebar__item @(controller == "Transactions" ? "sidebar__item--active" : "")">
|
||||
@* shopping cart icon *@
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15.55 13c.75 0 1.41-.41 1.75-1.03l3.58-6.49c.37-.66-.11-1.48-.87-1.48H5.21l-.94-2H1v2h2l3.6 7.59-1.35 2.44C4.52 15.37 5.48 17 7 17h12v-2H7l1.1-2h7.45zM6.16 6h12.15l-2.76 5H8.53L6.16 6zM7 18c-1.1 0-1.99.9-1.99 2S5.9 22 7 22s2-.9 2-2-.9-2-2-2zm10 0c-1.1 0-1.99.9-1.99 2s.89 2 1.99 2 2-.9 2-2-.9-2-2-2z"/></svg>
|
||||
<a class="sidebar__link" asp-controller="Transactions" asp-action="Index" asp-route-page="1">Transactions</a>
|
||||
</li>
|
||||
<li class="sidebar__item @(controller == "Items" ? "sidebar__item--active" : "")">
|
||||
@* category shapes icon *@
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M12 2l-5.5 9h11L12 2zm0 3.84L13.93 9h-3.87L12 5.84zM17.5 13c-2.49 0-4.5 2.01-4.5 4.5s2.01 4.5 4.5 4.5 4.5-2.01 4.5-4.5-2.01-4.5-4.5-4.5zm0 7c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5zM3 21.5h8v-8H3v8zm2-6h4v4H5v-4z"/></svg>
|
||||
<a class="sidebar__link" asp-controller="Items" asp-action="Index" asp-route-page="1">Items</a>
|
||||
</li>
|
||||
<li class="sidebar__item @(controller == "Stores" ? "sidebar__item--active" : "")">
|
||||
@* store building icon *@
|
||||
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M18.36 9l.6 3H5.04l.6-3h12.72M20 4H4v2h16V4zm0 3H4l-1 5v2h1v6h10v-6h4v6h2v-6h1v-2l-1-5zM6 18v-4h6v4H6z"/></svg>
|
||||
<a class="sidebar__link" asp-controller="Stores" asp-action="Index" asp-route-page="1">Stores</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
@*<div class="slide-toggle">
|
||||
<label class="slide-toggle__option">
|
||||
<svg class="icon icon--sm" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24"><rect fill="none" height="24" width="24"/><path d="M12,9c1.65,0,3,1.35,3,3s-1.35,3-3,3s-3-1.35-3-3S10.35,9,12,9 M12,7c-2.76,0-5,2.24-5,5s2.24,5,5,5s5-2.24,5-5 S14.76,7,12,7L12,7z M2,13l2,0c0.55,0,1-0.45,1-1s-0.45-1-1-1l-2,0c-0.55,0-1,0.45-1,1S1.45,13,2,13z M20,13l2,0c0.55,0,1-0.45,1-1 s-0.45-1-1-1l-2,0c-0.55,0-1,0.45-1,1S19.45,13,20,13z M11,2v2c0,0.55,0.45,1,1,1s1-0.45,1-1V2c0-0.55-0.45-1-1-1S11,1.45,11,2z M11,20v2c0,0.55,0.45,1,1,1s1-0.45,1-1v-2c0-0.55-0.45-1-1-1C11.45,19,11,19.45,11,20z M5.99,4.58c-0.39-0.39-1.03-0.39-1.41,0 c-0.39,0.39-0.39,1.03,0,1.41l1.06,1.06c0.39,0.39,1.03,0.39,1.41,0s0.39-1.03,0-1.41L5.99,4.58z M18.36,16.95 c-0.39-0.39-1.03-0.39-1.41,0c-0.39,0.39-0.39,1.03,0,1.41l1.06,1.06c0.39,0.39,1.03,0.39,1.41,0c0.39-0.39,0.39-1.03,0-1.41 L18.36,16.95z M19.42,5.99c0.39-0.39,0.39-1.03,0-1.41c-0.39-0.39-1.03-0.39-1.41,0l-1.06,1.06c-0.39,0.39-0.39,1.03,0,1.41 s1.03,0.39,1.41,0L19.42,5.99z M7.05,18.36c0.39-0.39,0.39-1.03,0-1.41c-0.39-0.39-1.03-0.39-1.41,0l-1.06,1.06 c-0.39,0.39-0.39,1.03,0,1.41s1.03,0.39,1.41,0L7.05,18.36z"/></svg>
|
||||
Light
|
||||
<input class="slide-toggle__control" type="radio" name="colorScheme" value="light" />
|
||||
</label>
|
||||
<label class="slide-toggle__option">
|
||||
<svg class="icon icon--sm" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24"><rect fill="none" height="24" width="24"/><path d="M9.37,5.51C9.19,6.15,9.1,6.82,9.1,7.5c0,4.08,3.32,7.4,7.4,7.4c0.68,0,1.35-0.09,1.99-0.27C17.45,17.19,14.93,19,12,19 c-3.86,0-7-3.14-7-7C5,9.07,6.81,6.55,9.37,5.51z M12,3c-4.97,0-9,4.03-9,9s4.03,9,9,9s9-4.03,9-9c0-0.46-0.04-0.92-0.1-1.36 c-0.98,1.37-2.58,2.26-4.4,2.26c-2.98,0-5.4-2.42-5.4-5.4c0-1.81,0.89-3.42,2.26-4.4C12.92,3.04,12.46,3,12,3L12,3z"/></svg>
|
||||
Dark
|
||||
<input class="slide-toggle__control" type="radio" name="colorScheme" value="dark" />
|
||||
</label>
|
||||
</div>*@
|
||||
</section>
|
9
Groceries/Common/_Modal.cshtml
Normal file
9
Groceries/Common/_Modal.cshtml
Normal file
@ -0,0 +1,9 @@
|
||||
<turbo-frame id="modal" data-modal-target="frame">
|
||||
<article class="card">
|
||||
<header class="card__header row">
|
||||
<h2 class="row__fill">@ViewBag.Title</h2>
|
||||
<button class="button modal__close-button" data-action="modal#close">🗙</button>
|
||||
</header>
|
||||
@RenderBody()
|
||||
</article>
|
||||
</turbo-frame>
|
33
Groceries/Common/_TablePaginator.cshtml
Normal file
33
Groceries/Common/_TablePaginator.cshtml
Normal file
@ -0,0 +1,33 @@
|
||||
@model IListPageModel
|
||||
@{
|
||||
var routeData = new Dictionary<string, string>(
|
||||
ViewContext.RouteData.Values
|
||||
.Where(data => data.Value != null)
|
||||
.Select(data => KeyValuePair.Create(data.Key, (string)data.Value!))
|
||||
.Concat(Context.Request.Query.Select(param => KeyValuePair.Create(param.Key, (string)param.Value!))));
|
||||
}
|
||||
|
||||
<div class="table__paginator">
|
||||
<span>
|
||||
Showing @(Model!.Offset + 1) to @(Model.Offset + Model.Count) of @Model.Total results
|
||||
</span>
|
||||
<nav class="button-group">
|
||||
@if (Model.Page == 1)
|
||||
{
|
||||
<span class="link link--disabled">Previous</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="link" asp-all-route-data="routeData" asp-route-page="@(Model.Page - 1)">Previous</a>
|
||||
}
|
||||
|
||||
@if (Model.Page == Model.LastPage)
|
||||
{
|
||||
<span class="link link--disabled">Next</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="link" asp-all-route-data="routeData" asp-route-page="@(Model.Page + 1)">Next</a>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
12
Groceries/Common/_TurboStream.cshtml
Normal file
12
Groceries/Common/_TurboStream.cshtml
Normal file
@ -0,0 +1,12 @@
|
||||
<turbo-stream action="@ViewBag.Action" target="@ViewBag.Target">
|
||||
@if (ViewBag.Action != "remove")
|
||||
{
|
||||
<template>
|
||||
@RenderBody()
|
||||
</template>
|
||||
}
|
||||
else
|
||||
{
|
||||
IgnoreBody();
|
||||
}
|
||||
</turbo-stream>
|
Reference in New Issue
Block a user