Initial commit

This commit is contained in:
James Chapman 2023-07-23 13:34:00 +01:00
commit 967c16b6bf
Signed by: jamsch0
GPG Key ID: 765FE58130277547
65 changed files with 2868 additions and 0 deletions

16
.editorconfig Normal file
View File

@ -0,0 +1,16 @@
root = true
[*]
charset = utf-8
end_of_line = crlf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.cs]
csharp_style_namespace_declarations = file_scoped:suggestion
csharp_using_directive_placement = inside_namespace:suggestion
# CA1710 Identifiers should have correct suffix
dotnet_diagnostic.CA1710.severity = suggestion

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.vs/
**/bin/
**/obj/
*.csproj.user
Groceries/wwwroot/lib/
Groceries/config_development.ini

5
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"recommendations": [
"ms-dotnettools.csdevkit"
]
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"dotnet.defaultSolution": "Groceries.sln"
}

View File

@ -0,0 +1,103 @@
namespace Groceries.Data;
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Item> Items => Set<Item>();
public DbSet<ItemPurchase> ItemPurchases => Set<ItemPurchase>();
public DbSet<ItemTagQuantity> ItemTagQuantities => Set<ItemTagQuantity>();
public DbSet<List> Lists => Set<List>();
public DbSet<Retailer> Retailers => Set<Retailer>();
public DbSet<Store> Stores => Set<Store>();
public DbSet<Transaction> Transactions => Set<Transaction>();
public DbSet<TransactionTotal> TransactionTotals => Set<TransactionTotal>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Item>(entity =>
{
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql();
});
modelBuilder.Entity<ItemBarcode>(entity =>
{
entity.ToTable("item_barcodes");
entity.HasKey(e => new { e.ItemId, e.BarcodeData });
entity.Property(e => e.Format)
.HasDefaultValueSql();
});
modelBuilder.Entity<ItemPurchase>(entity =>
{
entity.HasNoKey();
entity.ToView("item_purchases");
});
modelBuilder.Entity<ItemTagQuantity>(entity =>
{
entity.HasNoKey();
});
modelBuilder.Entity<List>(entity =>
{
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql();
entity.OwnsMany(entity => entity.Items, entity =>
{
entity.ToTable("list_items");
});
});
modelBuilder.Entity<Transaction>(entity =>
{
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql();
entity.OwnsMany(e => e.Items, entity =>
{
entity.ToTable("transaction_items");
entity.HasKey(e => new { e.TransactionId, e.ItemId });
entity.Property(e => e.Price)
.HasPrecision(5, 2);
});
});
modelBuilder.Entity<TransactionPromotion>(entity =>
{
entity.ToTable("transaction_promotions");
entity.Property(e => e.Amount)
.HasPrecision(5, 2);
entity.HasMany(e => e.Items)
.WithMany(e => e.TransactionPromotions)
.UsingEntity<TransactionPromotionItem>();
});
modelBuilder.Entity<TransactionTotal>(entity =>
{
entity.HasNoKey();
entity.ToView("transaction_totals");
});
foreach (var entity in modelBuilder.Model.GetEntityTypes())
{
var idProperty = entity.FindProperty("Id");
if (idProperty != null)
{
idProperty.SetColumnName($"{entity.ClrType.Name.ToLowerInvariant()}_id");
idProperty.SetDefaultValueSql(string.Empty);
}
}
}
}

View File

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
<AnalysisMode>recommended</AnalysisMode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="7.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.4" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,27 @@
namespace Groceries.Data;
public class Item
{
public Item(Guid id, string brand, string name)
{
Id = id;
Brand = brand;
Name = name;
}
public Item(Guid id) : this(id, default!, default!)
{
}
public Item(string brand, string name) : this(default, brand, name)
{
}
public Guid Id { get; init; }
public DateTime UpdatedAt { get; set; }
public string Brand { get; set; }
public string Name { get; set; }
public ICollection<ItemBarcode> Barcodes { get; init; } = new List<ItemBarcode>();
public IEnumerable<TransactionPromotion>? TransactionPromotions { get; init; }
}

View File

@ -0,0 +1,15 @@
namespace Groceries.Data;
public class ItemBarcode
{
public ItemBarcode(Guid itemId, long barcodeData, string format)
{
ItemId = itemId;
BarcodeData = barcodeData;
Format = format;
}
public Guid ItemId { get; init; }
public long BarcodeData { get; init; }
public string Format { get; init; }
}

View File

@ -0,0 +1,15 @@
namespace Groceries.Data;
public class ItemPurchase
{
public Guid ItemId { get; init; }
public Guid TransactionId { get; init; }
public DateTime CreatedAt { get; init; }
public Guid StoreId { get; init; }
public decimal Price { get; init; }
public int Quantity { get; init; }
public Item? Item { get; init; }
public Transaction? Transaction { get; init; }
public Store? Store { get; init; }
}

View File

@ -0,0 +1,10 @@
namespace Groceries.Data;
public class ItemTagQuantity
{
public required string Tag { get; init; }
public required decimal Quantity { get; init; }
public string? Unit { get; init; }
public bool IsMetric { get; init; }
public bool IsDivisible { get; init; }
}

View File

@ -0,0 +1,20 @@
namespace Groceries.Data;
public class List
{
public List(Guid id, string name)
{
Id = id;
Name = name;
}
public List(string name) : this(default, name)
{
}
public Guid Id { get; init; }
public DateTime UpdatedAt { get; set; }
public string Name { get; set; }
public ICollection<ListItem>? Items { get; init; }
}

View File

@ -0,0 +1,20 @@
namespace Groceries.Data;
public class ListItem
{
public ListItem(Guid id, Guid listId, string name)
{
Id = id;
ListId = listId;
Name = name;
}
public ListItem(Guid listId, string name) : this(default, listId, name)
{
}
public Guid Id { get; init; }
public Guid ListId { get; init; }
public string Name { get; set; }
public bool Completed { get; set; }
}

View File

@ -0,0 +1,17 @@
namespace Groceries.Data;
public class Retailer
{
public Retailer(Guid id, string name)
{
Id = id;
Name = name;
}
public Retailer(string name) : this(default, name)
{
}
public Guid Id { get; init; }
public string Name { get; set; }
}

View File

@ -0,0 +1,25 @@
namespace Groceries.Data;
public class Store
{
public Store(Guid id, Guid retailerId, string name, string? address = null)
{
Id = id;
RetailerId = retailerId;
Name = name;
Address = address;
}
public Store(Guid retailerId, string name, string? address = null)
: this(default, retailerId, name, address)
{
}
public Guid Id { get; init; }
public Guid RetailerId { get; init; }
public string Name { get; set; }
public string? Address { get; set; }
public Retailer? Retailer { get; init; }
public IEnumerable<Transaction>? Transactions { get; init; }
}

View File

@ -0,0 +1,27 @@
namespace Groceries.Data;
using System.Text.Json.Serialization;
public class Transaction
{
[JsonConstructor]
public Transaction(Guid id, DateTime createdAt, Guid storeId)
{
Id = id;
CreatedAt = createdAt;
StoreId = storeId;
}
public Transaction(DateTime createdAt, Guid storeId) : this(default, createdAt, storeId)
{
}
public Guid Id { get; init; }
public DateTime CreatedAt { get; init; }
public Guid StoreId { get; init; }
public ICollection<TransactionItem> Items { get; init; } = new List<TransactionItem>();
public ICollection<TransactionPromotion> Promotions { get; init; } = new List<TransactionPromotion>();
public Store? Store { get; init; }
}

View File

@ -0,0 +1,26 @@
namespace Groceries.Data;
using System.Text.Json.Serialization;
public class TransactionItem
{
[JsonConstructor]
public TransactionItem(Guid transactionId, Guid itemId, decimal price, int quantity)
{
TransactionId = transactionId;
ItemId = itemId;
Price = price;
Quantity = quantity;
}
public TransactionItem(Guid itemId, decimal price, int quantity) : this(default, itemId, price, quantity)
{
}
public Guid TransactionId { get; init; }
public Guid ItemId { get; init; }
public decimal Price { get; set; }
public int Quantity { get; set; }
public Item? Item { get; init; }
}

View File

@ -0,0 +1,21 @@
namespace Groceries.Data;
public class TransactionPromotion
{
public TransactionPromotion(Guid id, Guid transactionId, string name, decimal amount)
{
Id = id;
TransactionId = transactionId;
Name = name;
Amount = amount;
}
public Guid Id { get; set; }
public Guid TransactionId { get; set; }
public string Name { get; set; } = null!;
public decimal Amount { get; set; }
public ICollection<Item> Items { get; init; } = new List<Item>();
public Transaction? Transaction { get; init; }
}

View File

@ -0,0 +1,7 @@
namespace Groceries.Data;
internal sealed class TransactionPromotionItem
{
public Guid TransactionPromotionId { get; init; }
public Guid ItemId { get; init; }
}

View File

@ -0,0 +1,9 @@
namespace Groceries.Data;
public class TransactionTotal
{
public Guid TransactionId { get; init; }
public decimal Total { get; init; }
public Transaction? Transaction { get; init; }
}

37
Groceries.sln Normal file
View File

@ -0,0 +1,37 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.32014.148
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Groceries", "Groceries\Groceries.csproj", "{C370C8DD-B7A0-46F9-8AA0-569D7D98B354}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Groceries.Data", "Groceries.Data\Groceries.Data.csproj", "{4B8D5F1B-0F09-4626-A598-1400BF985637}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{D3B5FF09-44D2-491B-9663-0962BE0D1EBE}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
.gitignore = .gitignore
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C370C8DD-B7A0-46F9-8AA0-569D7D98B354}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C370C8DD-B7A0-46F9-8AA0-569D7D98B354}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C370C8DD-B7A0-46F9-8AA0-569D7D98B354}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C370C8DD-B7A0-46F9-8AA0-569D7D98B354}.Release|Any CPU.Build.0 = Release|Any CPU
{4B8D5F1B-0F09-4626-A598-1400BF985637}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4B8D5F1B-0F09-4626-A598-1400BF985637}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4B8D5F1B-0F09-4626-A598-1400BF985637}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4B8D5F1B-0F09-4626-A598-1400BF985637}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {BA46DEBB-75D8-4AFD-B07C-8B3F89820BF0}
EndGlobalSection
EndGlobal

View 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,
};
}
}

View 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");
}
}

View 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);
}
}

View 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($" &ndash; {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>

View 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>

View 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">&#x1F5D9;</button>
</header>
@RenderBody()
</article>
</turbo-frame>

View 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>

View File

@ -0,0 +1,12 @@
<turbo-stream action="@ViewBag.Action" target="@ViewBag.Target">
@if (ViewBag.Action != "remove")
{
<template>
@RenderBody()
</template>
}
else
{
IgnoreBody();
}
</turbo-stream>

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
<AnalysisMode>recommended</AnalysisMode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="7.0.9" />
<PackageReference Include="Microsoft.Web.LibraryManager.Build" Version="2.1.175" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Groceries.Data\Groceries.Data.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="config.ini" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,47 @@
namespace Groceries.Home;
using Groceries.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
[Route("/")]
public class HomeController : Controller
{
private readonly AppDbContext dbContext;
public HomeController(AppDbContext dbContext)
{
this.dbContext = dbContext;
}
[HttpGet]
public async Task<IActionResult> IndexAsync()
{
var randomTagQuantity = await dbContext.ItemTagQuantities
.FromSql($"""
SELECT tag, quantity, coalesce(unit_name, unit) AS unit, is_metric, is_divisible
FROM (
SELECT
unnest(tags) AS tag,
round(sum((item_quantity->'amount')::numeric * quantity), 1) AS quantity,
item_quantity->>'unit' AS unit,
item_quantity->'is_metric' AS is_metric,
item_quantity->'is_divisible' AS is_divisible
FROM item_purchases
JOIN items USING (item_id)
CROSS JOIN item_quantity(name)
WHERE array_length(tags, 1) > 0
AND age(created_at) <= '90 days'
AND item_quantity IS NOT NULL
GROUP BY tag, item_quantity->>'unit', item_quantity->'is_metric', item_quantity->'is_divisible'
ORDER BY random()
FETCH FIRST ROW ONLY
) AS random_item_tag_quantity
LEFT JOIN item_tags USING (tag)
""")
.FirstOrDefaultAsync();
return View(randomTagQuantity);
}
}

View File

@ -0,0 +1,42 @@
@using Groceries.Data
@using Humanizer
@model ItemTagQuantity?
@section head {
@*<meta name="turbo-cache-control" content="no-preview" />*@
}
<section class="card">
<header class="card__header">
<h2>Item Quantity (last 90 days)</h2>
</header>
<div class="card__content">
@if (Model != null)
{
@if (Model.IsDivisible)
{
var quantity = Convert.ToDouble(Model.Quantity);
var weekQuantity = Math.Round(quantity / 12);
<strong>@(Model.IsMetric ? quantity.ToMetric() : quantity)@Model.Unit @Model.Tag</strong>
<small>(@(Model.IsMetric ? weekQuantity.ToMetric() : weekQuantity)@Model.Unit per week)</small>
}
else
{
var name = Model.Unit != null ? $"{Model.Tag} {Model.Unit}" : Model.Tag;
var avgQuantity = Model.Quantity / 12;
var avgPeriod = "week";
if (avgQuantity < 1)
{
avgQuantity *= 4;
avgPeriod = "month";
}
<strong>@name.ToQuantity(Convert.ToInt32(Model.Quantity))</strong>
<small>(@name.ToQuantity(Convert.ToInt32(avgQuantity)) per @avgPeriod)</small>
}
}
</div>
</section>

View File

@ -0,0 +1,25 @@
@using Groceries.Data
@model Item
@{
Layout = ViewBag.RenderingToTurboStream == true ? null : "_Modal";
ViewBag.Title = "Edit Item";
}
<h1>Edit Item</h1>
<form asp-action="EditItem" method="post">
<div class="form-field">
<div class="form-field__control input">
<input class="input__control" name="brand" value="@Model.Brand" placeholder="Brand" />
</div>
</div>
<div class="form-field">
<div class="form-field__control input">
<input class="input__control" name="name" value="@Model.Name" placeholder="Name" />
</div>
</div>
<button class="button button--flat" type="reset" data-action="modal#close">Cancel</button>
<button class="button button--flat">Save</button>
</form>

View File

@ -0,0 +1,55 @@
@using Groceries.Items
@model ItemListModel
@{
ViewBag.Title = "Items";
}
<div class="row">
<h1 class="row__fill">Items</h1>
<form method="get" data-controller="search-form" data-turbo-frame="table" data-turbo-action="advance">
<input type="hidden" name="page" value="1" />
<div class="form-field">
<div class="form-field__control input">
<div class="input__inset">
@* Search icon *@
<svg class="icon icon--sm" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0V0z" fill="none" /><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" /></svg>
</div>
<input class="input__control" type="search" name="search" value="@Model.Search" placeholder="Search" autocomplete="off" data-action="search-form#input" />
<button class="input__addon button" data-search-form-target="button">Search</button>
</div>
</div>
</form>
</div>
<turbo-frame id="table" target="_top">
<section class="table">
<table>
<thead>
<tr>
<th scope="col" class="table__header">Brand</th>
<th scope="col" class="table__header" style="width: 100%">Name</th>
<th scope="col" class="table__header">Last Purchased</th>
<th scope="col" class="table__header">Barcode</th>
@*<th scope="col" class="table__header"></th>*@
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr>
<td class="table__cell">@item.Brand</td>
<td class="table__cell">@item.Name</td>
<td class="table__cell">
<time datetime="@item.LastPurchasedAt?.ToString("o")">@item.LastPurchasedAt?.ToLongDateString()</time>
</td>
<td class="table__cell table__cell--icon" style="width: fit-content">@(item.HasBarcode ? "✓" : "")</td>
@*<td class="table__cell">
<a class="link" asp-action="EditItem" asp-route-id="@item.Id">Edit</a>
</td>*@
</tr>
}
</tbody>
</table>
<partial name="_TablePaginator" model="Model.Items" />
</section>
</turbo-frame>

View File

@ -0,0 +1,10 @@
namespace Groceries.Items;
public record ItemListModel(string? Search, ListPageModel<ItemListModel.Item> Items)
{
public record Item(Guid Id, string Brand, string Name)
{
public bool HasBarcode { get; init; }
public DateTime? LastPurchasedAt { get; init; }
}
}

View File

@ -0,0 +1,94 @@
namespace Groceries.Items;
using Groceries.Common;
using Groceries.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
[Route("/items")]
public class ItemsController : Controller
{
private readonly AppDbContext dbContext;
public ItemsController(AppDbContext dbContext)
{
this.dbContext = dbContext;
}
[HttpGet]
public async Task<IActionResult> Index(int page, string? search)
{
var itemsQuery = dbContext.Items.AsQueryable();
if (!string.IsNullOrEmpty(search))
{
var searchPattern = $"%{search}%";
itemsQuery = itemsQuery.Where(item => EF.Functions.ILike(item.Brand + ' ' + item.Name, searchPattern));
}
var lastPurchasesQuery = dbContext.ItemPurchases
.GroupBy(purchase => purchase.ItemId)
.Select(purchases => new
{
ItemId = purchases.Key,
CreatedAt = purchases.Max(purchase => purchase.CreatedAt),
});
var items = await itemsQuery
.OrderBy(item => item.Brand)
.ThenBy(item => item.Name)
.GroupJoin(
lastPurchasesQuery,
item => item.Id,
lastPurchase => lastPurchase.ItemId,
(item, lastPurchase) => new { item, lastPurchase })
.SelectMany(
group => group.lastPurchase.DefaultIfEmpty(),
(group, lastPurchase) => new ItemListModel.Item(group.item.Id, group.item.Brand, group.item.Name)
{
HasBarcode = group.item.Barcodes.Any(),
LastPurchasedAt = lastPurchase != null ? lastPurchase.CreatedAt : null,
})
.ToListPageModelAsync(page, cancellationToken: HttpContext.RequestAborted);
if (items.Page != page)
{
return RedirectToAction(nameof(Index), new { page = items.Page, search });
}
var model = new ItemListModel(search, items);
return View(model);
}
[HttpGet("{id}")]
public async Task<IActionResult> EditItem(Guid id)
{
var item = await dbContext.Items
.SingleOrDefaultAsync(item => item.Id == id, HttpContext.RequestAborted);
if (item == null)
{
return NotFound();
}
return View(item);
}
[HttpPost("{id}")]
public async Task<IActionResult> EditItem(Guid id, string brand, string name)
{
var item = await dbContext.Items
.SingleOrDefaultAsync(item => item.Id == id, HttpContext.RequestAborted);
if (item == null)
{
return NotFound();
}
if (Request.AcceptsTurboStream())
{
return this.TurboStream(TurboStreamAction.Replace, "modal-body", item);
}
return RedirectToAction(nameof(EditItem));
}
}

View File

@ -0,0 +1,88 @@
namespace Groceries;
using Microsoft.EntityFrameworkCore;
using System.Collections;
public interface IListPageModel
{
int Offset { get; }
int Page { get; }
int PageSize { get; }
int LastPage { get; }
int Total { get; }
int Count { get; }
}
public record ListPageModel<TItem> : IListPageModel, IReadOnlyCollection<TItem>
{
public ListPageModel(IList<TItem> items)
{
Items = items;
}
public int Offset { get; init; }
public int Page { get; init; }
public int PageSize { get; init; }
public int LastPage { get; init; }
public int Total { get; init; }
public IList<TItem> Items { get; init; }
public int Count => Items.Count;
public IEnumerator<TItem> GetEnumerator()
{
return Items.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return Items.GetEnumerator();
}
}
public static class ListPageModel
{
public static ListPageModel<TItem> Empty<TItem>()
{
return new ListPageModel<TItem>(Array.Empty<TItem>());
}
}
public static class ListPageModelExtensions
{
public static async Task<ListPageModel<TItem>> ToListPageModelAsync<TItem>(
this IQueryable<TItem> query,
int page,
int pageSize = 10,
CancellationToken cancellationToken = default)
{
if (page < 1)
{
return new ListPageModel<TItem>(Array.Empty<TItem>()) { Page = 1 };
}
var total = await query.CountAsync(cancellationToken);
var lastPage = Math.Max(1, (int)Math.Ceiling((float)total / pageSize));
if (page > lastPage)
{
return new ListPageModel<TItem>(Array.Empty<TItem>()) { Page = lastPage };
}
var offset = (page - 1) * pageSize;
var items = await query
.Skip(offset)
.Take(pageSize)
.ToArrayAsync(cancellationToken);
return new ListPageModel<TItem>(items)
{
Offset = offset,
Page = page,
PageSize = pageSize,
LastPage = lastPage,
Total = total,
};
}
}

48
Groceries/Program.cs Normal file
View File

@ -0,0 +1,48 @@
using Groceries.Common;
using Groceries.Data;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddIniFile("config.ini", optional: true, reloadOnChange: true)
.AddIniFile($"config_{builder.Environment.EnvironmentName}.ini", optional: true, reloadOnChange: true);
var mvc = builder.Services
.AddControllersWithViews()
.AddRazorOptions(options =>
{
options.ViewLocationFormats.Clear();
options.ViewLocationFormats.Add("/{1}/{0}" + RazorViewEngine.ViewExtension);
options.ViewLocationFormats.Add("/Common/{0}" + RazorViewEngine.ViewExtension);
})
.AddSessionStateTempDataProvider();
if (builder.Environment.IsDevelopment())
{
mvc.AddRazorRuntimeCompilation();
}
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession();
builder.Services.AddSingleton<IActionResultExecutor<TurboStreamResult>, TurboStreamResultExecutor>();
builder.Services.AddDbContextPool<AppDbContext>(options => options
.EnableDetailedErrors(builder.Environment.IsDevelopment())
.EnableSensitiveDataLogging(builder.Environment.IsDevelopment())
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)
.UseSnakeCaseNamingConvention()
.UseNpgsql(builder.Configuration["Database"]!));
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseSession();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,13 @@
{
"profiles": {
"Groceries": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7021;http://localhost:5021",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,36 @@
@using Microsoft.AspNetCore.Http
@using Microsoft.AspNetCore.Http.Extensions;
@model Groceries.Data.Store
@{
ViewBag.Title = "Edit Store";
var returnUrl = Url.Action("Index", new { page = 1 });
if (Context.Request.GetTypedHeaders().Referer is Uri referer && referer.Host == Context.Request.Host.Host)
{
var requestUrl = new UriBuilder
{
Scheme = Context.Request.Scheme,
Host = Context.Request.Host.Host,
Port = Context.Request.Host.Port.GetValueOrDefault(-1),
Path = Context.Request.Path.ToString(),
Query = Context.Request.QueryString.ToString(),
}.Uri;
if (referer != requestUrl)
{
returnUrl = referer.PathAndQuery;
}
}
}
<h1>Edit Store</h1>
<form method="post" asp-action="EditStore">
<partial name="_StoreForm" />
<div class="row">
<button class="button button--primary" type="submit">Save</button>
<a class="button" href="@returnUrl">Cancel</a>
</div>
</form>

View File

@ -0,0 +1,14 @@
@model Groceries.Data.Store
@{
Layout = "_Modal";
ViewBag.Title = "Edit Store";
}
<form class="card__content" id="editStore" method="post" asp-action="EditStore" data-action="turbo:submit-end->modal#close">
<partial name="_StoreForm" />
</form>
<footer class="card__footer card__footer--shaded row">
<button class="button button--primary" type="submit" form="editStore">Save</button>
<button class="button" data-action="modal#close">Cancel</button>
</footer>

View File

@ -0,0 +1,52 @@
@using Groceries.Stores
@model StoreListModel
@{
ViewBag.Title = "Stores";
}
<div class="row">
<h1 class="row__fill">Stores</h1>
<form method="get" data-controller="search-form" data-turbo-frame="table" data-turbo-action="advance">
<input type="hidden" name="page" value="1" />
<div class="form-field">
<div class="form-field__control input input--leading-inset input--trailing-addon">
<div class="input__inset">
@* Search icon *@
<svg class="icon icon--sm" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0V0z" fill="none" /><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" /></svg>
</div>
<input class="input__control" type="search" name="search" value="@Model.Search" placeholder="Search" autocomplete="off" data-action="search-form#input" />
<button class="input__addon button" data-search-form-target="button">Search</button>
</div>
</div>
</form>
<a class="button button--primary" asp-action="NewStore" data-turbo-frame="modal">New store</a>
</div>
<turbo-frame id="table" target="_top">
<section class="table">
<table>
<thead>
<tr>
<th scope="col" class="table__header">Retailer</th>
<th scope="col" class="table__header" style="width: 100%">Name</th>
<th scope="col" class="table__header">Transactions</th>
<th scope="col" class="table__header"></th>
</tr>
</thead>
<tbody>
@foreach (var store in Model.Stores)
{
<tr>
<td class="table__cell">@store.Retailer</td>
<td class="table__cell">@store.Name</td>
<td class="table__cell table__cell--numeric">@store.TotalTransactions</td>
<td class="table__cell">
<a class="link" asp-action="EditStore" asp-route-id="@store.Id" data-turbo-frame="modal">Edit</a>
</td>
</tr>
}
</tbody>
</table>
<partial name="_TablePaginator" model="Model.Stores" />
</section>
</turbo-frame>

View File

@ -0,0 +1,35 @@
@using Microsoft.AspNetCore.Http
@using Microsoft.AspNetCore.Http.Extensions;
@{
ViewBag.Title = "New Store";
var returnUrl = Url.Action("Index", new { page = 1 });
if (Context.Request.GetTypedHeaders().Referer is Uri referer && referer.Host == Context.Request.Host.Host)
{
var requestUrl = new UriBuilder
{
Scheme = Context.Request.Scheme,
Host = Context.Request.Host.Host,
Port = Context.Request.Host.Port.GetValueOrDefault(-1),
Path = Context.Request.Path.ToString(),
Query = Context.Request.QueryString.ToString(),
}.Uri;
if (referer != requestUrl)
{
returnUrl = referer.PathAndQuery;
}
}
}
<h1>New Store</h1>
<form method="post" asp-action="NewStore">
<partial name="_StoreForm" />
<div class="row">
<button class="button button--primary">Save</button>
<a class="button" href="@returnUrl">Cancel</a>
</div>
</form>

View File

@ -0,0 +1,13 @@
@{
Layout = "_Modal";
ViewBag.Title = "New Store";
}
<form class="card__content" id="newStore" method="post" asp-action="NewStore" data-action="turbo:submit-end->modal#close">
<partial name="_StoreForm" />
</form>
<footer class="card__footer card__footer--shaded row">
<button class="button button--primary" type="submit" form="newStore">Save</button>
<button class="button" data-action="modal#close">Cancel</button>
</footer>

View File

@ -0,0 +1,9 @@
namespace Groceries.Stores;
public record StoreListModel(string? Search, ListPageModel<StoreListModel.Store> Stores)
{
public record Store(Guid Id, string Retailer, string Name)
{
public int TotalTransactions { get; init; }
}
}

View File

@ -0,0 +1,95 @@
namespace Groceries.Stores;
using Groceries.Common;
using Groceries.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
[Route("/stores")]
public class StoresController : Controller
{
private readonly AppDbContext dbContext;
public StoresController(AppDbContext dbContext)
{
this.dbContext = dbContext;
}
[HttpGet]
public async Task<IActionResult> Index(int page, string? search)
{
var storesQuery = dbContext.Stores.AsQueryable();
if (!string.IsNullOrEmpty(search))
{
var searchPattern = $"%{search}%";
storesQuery = storesQuery.Where(store => EF.Functions.ILike(store.Retailer!.Name + ' ' + store.Name, searchPattern));
}
var stores = await storesQuery
.OrderBy(store => store.Retailer!.Name)
.ThenBy(store => store.Name)
.Select(store => new StoreListModel.Store(store.Id, store.Retailer!.Name, store.Name)
{
TotalTransactions = store.Transactions!.Count(),
})
.ToListPageModelAsync(page, cancellationToken: HttpContext.RequestAborted);
if (stores.Page != page)
{
return RedirectToAction(nameof(Index), new { page = stores.Page, search });
}
var model = new StoreListModel(search, stores);
return View(model);
}
[HttpGet("new")]
public IActionResult NewStore()
{
return Request.IsTurboFrameRequest("modal")
? View($"{nameof(NewStore)}_Modal")
: View();
}
[HttpPost("new")]
public async Task<IActionResult> NewStore(Guid retailerId, string name, string? address)
{
var store = new Store(retailerId, name, address);
dbContext.Stores.Add(store);
await dbContext.SaveChangesAsync(HttpContext.RequestAborted);
return Request.IsTurboFrameRequest("modal")
? NoContent()
: RedirectToAction(nameof(Index), new { page = 1 });
}
[HttpGet("edit/{id}")]
public async Task<IActionResult> EditStore(Guid id)
{
var store = await dbContext.Stores
.SingleOrDefaultAsync(store => store.Id == id, HttpContext.RequestAborted);
if (store == null)
{
return NotFound();
}
return Request.IsTurboFrameRequest("modal")
? View($"{nameof(EditStore)}_Modal", store)
: View(store);
}
[HttpPost("edit/{id}")]
public async Task<IActionResult> EditStore(Guid id, Guid retailerId, string name, string? address)
{
var store = new Store(id, retailerId, name, address);
dbContext.Stores.Update(store);
await dbContext.SaveChangesAsync(HttpContext.RequestAborted);
return Request.IsTurboFrameRequest("modal")
? NoContent()
: RedirectToAction(nameof(EditStore));
}
}

View File

@ -0,0 +1,35 @@
@using Groceries.Data
@using Microsoft.EntityFrameworkCore
@model Store?
@inject AppDbContext dbContext
@{
var retailers = await dbContext.Retailers
.OrderBy(retailer => retailer.Name)
.ToListAsync();
}
<div class="form-field">
<label class="form-field__label" for="storeRetailerId">Retailer</label>
<select class="form-field__control select" id="storeRetailerId" name="retailerId" required autofocus>
@foreach (var retailer in retailers)
{
<option value="@retailer.Id" selected="@(retailer.Id == Model?.RetailerId)">@retailer.Name</option>
}
</select>
</div>
<div class="form-field">
<label class="form-field__label" for="storeName">Name</label>
<div class="form-field__control input">
<input class="input__control" id="storeName" name="name" value="@Model?.Name" required />
</div>
</div>
<div class="form-field">
<label class="form-field__label" for="storeAddress">
Address
<span class="form-field__corner-hint">Optional</span>
</label>
<textarea class="form-field__control textarea" id="storeAddress" name="address" rows="4">@Model?.Address</textarea>
</div>

View File

@ -0,0 +1,26 @@
@using Groceries.Data;
@using Microsoft.EntityFrameworkCore;
@model (Transaction Transaction, TransactionItem TransactionItem)
@inject AppDbContext dbContext
@{
ViewBag.Title = "Edit Transaction Item";
var store = await dbContext.Stores
.Where(store => store.Id == Model.Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync();
}
<h1>Edit Transaction Item</h1>
<div>@Model.Transaction.CreatedAt.ToShortDateString() @Model.Transaction.CreatedAt.ToShortTimeString() &ndash; @store</div>
<form method="post" asp-action="EditTransactionItem">
<partial name="_TransactionItemForm" model="Model.TransactionItem" />
<div class="row">
<button class="button button--primary" type="submit">Update</button>
<a class="button" asp-action="EditTransactionItems">Cancel</a>
</div>
</form>

View File

@ -0,0 +1,20 @@
@using Groceries.Data
@model (Transaction Transaction, TransactionItem TransactionItem)
@{
Layout = "_Modal";
ViewBag.Title = "Edit Transaction Item";
}
<form class="card__content" id="editTransactionItem" method="post" asp-action="EditTransactionItem" data-action="turbo:submit-end->modal#close">
<partial name="_TransactionItemForm" model="Model.TransactionItem" />
</form>
<form id="deleteTransactionItem" method="post" asp-action="DeleteTransactionItem" asp-route-id="@Model.TransactionItem.ItemId" data-action="turbo:submit-end->modal#close"></form>
<footer class="card__footer card__footer--shaded row">
<button class="button button--primary" type="submit" form="editTransactionItem">Update</button>
<button class="button" data-action="modal#close">Cancel</button>
<span class="row__fill"></span>
<button class="button button--danger" type="submit" form="deleteTransactionItem">Remove</button>
</footer>

View File

@ -0,0 +1,81 @@
@using Groceries.Transactions
@using Microsoft.AspNetCore.Html;
@model TransactionListModel
@{
ViewBag.Title = "Transactions";
string? GetNextSortDir(string col)
{
if (col != Model.Sort)
{
return "asc";
}
return Model.Dir switch
{
null or "" => "asc",
"asc" => "desc",
_ => null,
};
}
string? GetSortDir(string col)
{
if (col != Model.Sort)
{
return null;
}
return Model.Dir switch
{
"asc" or "desc" => Model.Dir,
_ => null,
};
}
}
<div class="row">
<h1 class="row__fill">Transactions</h1>
<a class="button button--primary form-field" asp-action="NewTransaction">New transaction</a>
</div>
<section class="table">
<table>
<thead>
<tr>
<th scope="col" class="table__header table__header--sortable">
<a asp-route-sort="@(GetNextSortDir("date") != null ? "date" : "")" asp-route-dir="@GetNextSortDir("date")" asp-route-page="1" data-dir="@GetSortDir("date")">
Date
</a>
</th>
<th scope="col" class="table__header" style="width: 100%">Store</th>
<th scope="col" class="table__header table__header--sortable">
<a asp-route-sort="@(GetNextSortDir("items") != null ? "items" : "")" asp-route-dir="@GetNextSortDir("items")" asp-route-page="1" data-dir="@GetSortDir("items")">
Items
</a>
</th>
<th scope="col" class="table__header table__header--sortable">
<a asp-route-sort="@(GetNextSortDir("amount") != null ? "amount" : "")" asp-route-dir="@GetNextSortDir("amount")" asp-route-page="1" data-dir="@GetSortDir("amount")">
Amount
</a>
</th>
@*<th scope="col" class="table__header"></th>*@
</tr>
</thead>
<tbody>
@foreach (var transaction in Model.Transactions)
{
<tr>
<td class="table__cell">
<time datetime="@transaction.CreatedAt.ToString("o")">@transaction.CreatedAt.ToLongDateString()</time>
</td>
<td class="table__cell">@transaction.Store</td>
<td class="table__cell table__cell--numeric">@transaction.TotalItems</td>
<td class="table__cell table__cell--numeric">@transaction.TotalAmount.ToString("c")</td>
@*<td class="table__cell">View</td>*@
</tr>
}
</tbody>
</table>
<partial name="_TablePaginator" model="Model.Transactions" />
</section>

View File

@ -0,0 +1,41 @@
@using Groceries.Data;
@using Microsoft.EntityFrameworkCore;
@inject AppDbContext dbContext
@{
ViewBag.Title = "New Transaction";
var datetime = DateTime.Now.ToString("s");
var stores = await dbContext.Stores
.OrderBy(store => store.Retailer!.Name)
.ThenBy(store => store.Name)
.Select(store => new { store.Id, Name = string.Concat(store.Retailer!.Name, " ", store.Name) })
.ToListAsync();
}
<h1>New Transaction</h1>
<form method="post" asp-action="NewTransaction">
<div class="form-field">
<label class="form-field__label" for="transactionCreatedAt">Date</label>
<div class="form-field__control input">
<input class="input__control" id="transactionCreatedAt" name="createdAt" type="datetime-local" value="@datetime" max="@datetime" step="1" required autofocus />
</div>
</div>
<div class="form-field">
<label class="form-field__label" for="transactionStoreId">Store</label>
<select class="form-field__control select" id="transactionStoreId" name="storeId" required>
@foreach (var store in stores)
{
<option value="@store.Id">@store.Name</option>
}
</select>
</div>
<div class="row">
<button class="button button--primary" type="submit">Next</button>
<a class="button" asp-action="Index">Cancel</a>
</div>
</form>

View File

@ -0,0 +1,26 @@
@using Groceries.Data;
@using Microsoft.EntityFrameworkCore;
@model Transaction
@inject AppDbContext dbContext
@{
ViewBag.Title = "New Transaction Item";
var store = await dbContext.Stores
.Where(store => store.Id == Model.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync();
}
<h1>New Transaction Item</h1>
<div>@Model.CreatedAt.ToShortDateString() @Model.CreatedAt.ToShortTimeString() &ndash; @store</div>
<form method="post" asp-action="NewTransactionItem">
<partial name="_TransactionItemForm" model="null" />
<div class="row">
<button class="button button--primary" type="submit">Add</button>
<a class="button" asp-action="NewTransactionItems">Cancel</a>
</div>
</form>

View File

@ -0,0 +1,16 @@
@using Groceries.Data
@model Transaction
@{
Layout = "_Modal";
ViewBag.Title = "New Transaction Item";
}
<form class="card__content" id="newTransactionItem" method="post" asp-action="NewTransactionItem" data-action="turbo:submit-end->modal#close">
<partial name="_TransactionItemForm" model="null" />
</form>
<footer class="card__footer card__footer--shaded row">
<button class="button button--primary" type="submit" form="newTransactionItem">Add</button>
<button class="button" data-action="modal#close">Cancel</button>
</footer>

View File

@ -0,0 +1,56 @@
@using Groceries.Data;
@using Microsoft.EntityFrameworkCore;
@model Transaction
@inject AppDbContext dbContext
@{
ViewBag.Title = "New Transaction";
var store = await dbContext.Stores
.Where(store => store.Id == Model.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync();
var itemIds = Model.Items.Select(item => item.ItemId);
var items = await dbContext.Items
.Where(item => itemIds.Contains(item.Id))
.Select(item => new { item.Id, Name = string.Concat(item.Brand, " ", item.Name) })
.ToListAsync();
}
<h1>New Transaction</h1>
<div>@Model.CreatedAt.ToShortDateString() @Model.CreatedAt.ToLongTimeString() &ndash; @store</div>
<form method="post" asp-action="NewTransactionItems">
<div class="card">
<div class="card__header row">
<h2 class="row__fill">Items</h2>
<a class="button button--primary" asp-action="NewTransactionItem" autofocus data-turbo-frame="modal">New item</a>
</div>
<div class="card__content">
<ul>
@foreach (var item in Model.Items)
{
<li class="row">
<span class="row__fill">@items.Single(i => i.Id == item.ItemId).Name</span>
<span>@item.Price.ToString("c")</span>
<span>@item.Quantity</span>
<span>@((item.Price * item.Quantity).ToString("c"))</span>
<a class="link" asp-action="EditTransactionItem" asp-route-id="@item.ItemId" data-turbo-frame="modal">Edit</a>
</li>
}
</ul>
</div>
<div class="card__footer">
Total: @Model.Items.Sum(item => item.Price * item.Quantity).ToString("c")
</div>
</div>
<div class="row">
<button class="button button--primary" type="submit">Save</button>
<a class="button" asp-action="Index">Cancel</a>
</div>
</form>

View File

@ -0,0 +1,10 @@
namespace Groceries.Transactions;
public record TransactionListModel(string? Sort, string? Dir, ListPageModel<TransactionListModel.Transaction> Transactions)
{
public record Transaction(Guid Id, DateTime CreatedAt, string Store)
{
public decimal TotalAmount { get; init; }
public int TotalItems { get; init; }
}
}

View File

@ -0,0 +1,237 @@
namespace Groceries.Transactions;
using Groceries.Common;
using Groceries.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
[Route("/transactions")]
public class TransactionsController : Controller
{
private readonly AppDbContext dbContext;
public TransactionsController(AppDbContext dbContext)
{
this.dbContext = dbContext;
}
[HttpGet]
public async Task<IActionResult> Index(int page, string? sort, string? dir)
{
var transactionsQuery = dbContext.Transactions
.Join(
dbContext.TransactionTotals,
transaction => transaction.Id,
transactionTotal => transactionTotal.TransactionId,
(transaction, transactionTotal) => new
{
transaction.Id,
transaction.CreatedAt,
Store = string.Concat(transaction.Store!.Retailer!.Name, " ", transaction.Store.Name),
TotalAmount = transactionTotal.Total,
TotalItems = transaction.Items.Sum(item => item.Quantity),
});
transactionsQuery = sort?.ToLowerInvariant() switch
{
"date" when dir == "desc" => transactionsQuery.OrderByDescending(transaction => transaction.CreatedAt),
"amount" when dir == "desc" => transactionsQuery.OrderByDescending(transaction => transaction.TotalAmount),
"items" when dir == "desc" => transactionsQuery.OrderByDescending(transaction => transaction.TotalItems),
"date" => transactionsQuery.OrderBy(transaction => transaction.CreatedAt),
"amount" => transactionsQuery.OrderBy(transaction => transaction.TotalAmount),
"items" => transactionsQuery.OrderBy(transaction => transaction.TotalItems),
_ => transactionsQuery.OrderByDescending(transaction => transaction.CreatedAt),
};
var transactions = await transactionsQuery
.Select(transaction => new TransactionListModel.Transaction(transaction.Id, transaction.CreatedAt, transaction.Store)
{
TotalAmount = transaction.TotalAmount,
TotalItems = transaction.TotalItems,
})
.ToListPageModelAsync(page);
if (transactions.Page != page)
{
return RedirectToAction(nameof(Index), new { page = transactions.Page });
}
var model = new TransactionListModel(sort, dir, transactions);
return View(model);
}
[HttpGet("new")]
public IActionResult NewTransaction()
{
return View();
}
[HttpPost("new")]
public IActionResult NewTransaction(DateTime createdAt, Guid storeId)
{
var transaction = new Transaction(createdAt.ToUniversalTime(), storeId);
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);
return RedirectToAction(nameof(NewTransactionItems));
}
[HttpGet("new/items")]
public IActionResult NewTransactionItems()
{
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
return RedirectToAction(nameof(NewTransaction));
}
return View(transaction);
}
[HttpPost("new/items")]
public async Task<IActionResult> PostNewTransactionItems()
{
if (TempData["NewTransaction"] is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
return RedirectToAction(nameof(NewTransaction));
}
dbContext.Transactions.Add(transaction);
await dbContext.SaveChangesAsync();
return RedirectToAction(nameof(Index), new { page = 1 });
}
[HttpGet("new/items/new")]
public IActionResult NewTransactionItem()
{
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
return RedirectToAction(nameof(NewTransaction));
}
return Request.IsTurboFrameRequest("modal")
? View($"{nameof(NewTransactionItem)}_Modal", transaction)
: View(transaction);
}
[HttpPost("new/items/new")]
public async Task<IActionResult> NewTransactionItem(string brand, string name, decimal price, int quantity)
{
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
return RedirectToAction(nameof(NewTransaction));
}
var itemId = await dbContext.Items
.Where(item => EF.Functions.ILike(item.Brand, brand) && EF.Functions.ILike(item.Name, name))
.Select(item => item.Id)
.SingleOrDefaultAsync();
if (itemId == default)
{
var item = new Item(brand, name);
dbContext.Items.Add(item);
await dbContext.SaveChangesAsync();
itemId = item.Id;
}
// TODO: Handle item already in transaction - merge, replace, error?
var transactionItem = new TransactionItem(itemId, price, quantity);
transaction.Items.Add(transactionItem);
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);
return Request.IsTurboFrameRequest("modal")
? NoContent()
: RedirectToAction(nameof(NewTransactionItems));
}
[HttpGet("new/items/edit/{id}")]
public IActionResult EditTransactionItem(Guid id)
{
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
return RedirectToAction(nameof(NewTransaction));
}
var transactionItem = transaction.Items.SingleOrDefault(item => item.ItemId == id);
if (transactionItem == null)
{
return RedirectToAction(nameof(NewTransactionItems));
}
var model = (transaction, transactionItem);
return Request.IsTurboFrameRequest("modal")
? View($"{nameof(EditTransactionItem)}_Modal", model)
: View(model);
}
[HttpPost("new/items/edit/{id}")]
public async Task<IActionResult> EditTransactionItem(Guid id, string brand, string name, decimal price, int quantity)
{
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
return RedirectToAction(nameof(NewTransaction));
}
var transactionItem = transaction.Items.SingleOrDefault(item => item.ItemId == id);
if (transactionItem == null)
{
return RedirectToAction(nameof(NewTransactionItems));
}
var itemId = await dbContext.Items
.Where(item => EF.Functions.ILike(item.Brand, brand) && EF.Functions.ILike(item.Name, name))
.Select(item => item.Id)
.SingleOrDefaultAsync();
if (itemId == transactionItem.ItemId)
{
transactionItem.Price = price;
transactionItem.Quantity = quantity;
}
else
{
if (itemId == default)
{
var item = new Item(brand, name);
dbContext.Items.Add(item);
await dbContext.SaveChangesAsync();
itemId = item.Id;
}
transaction.Items.Remove(transactionItem);
transactionItem = new TransactionItem(itemId, price, quantity);
transaction.Items.Add(transactionItem);
}
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);
return Request.IsTurboFrameRequest("modal")
? NoContent()
: RedirectToAction(nameof(NewTransactionItems));
}
[HttpPost("new/items/delete/{id}")]
public IActionResult DeleteTransactionItem(Guid id)
{
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
return RedirectToAction(nameof(NewTransaction));
}
var transactionItem = transaction.Items.SingleOrDefault(item => item.ItemId == id);
if (transactionItem != null)
{
transaction.Items.Remove(transactionItem);
}
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);
return Request.IsTurboFrameRequest("modal")
? NoContent()
: RedirectToAction(nameof(NewTransactionItems));
}
}

View File

@ -0,0 +1,49 @@
@using Groceries.Data
@using Microsoft.EntityFrameworkCore
@model TransactionItem?
@inject AppDbContext dbContext
@{
var items = await dbContext.Items
.OrderBy(item => item.Brand)
.ToListAsync();
var selectedItem = items.SingleOrDefault(item => item.Id == Model?.ItemId);
}
<fieldset class="form-field">
<legend class="form-field__label">Item</legend>
<div class="form-field__control input" data-controller="list-filter">
<input class="input__control flex-2" name="brand" value="@selectedItem?.Brand" placeholder="Brand" list="itemBrands" autocomplete="off" required autofocus data-action="list-filter#filter" />
<input class="input__control flex-5" name="name" value="@selectedItem?.Name" placeholder="Name" list="itemNames" autocomplete="off" required />
<datalist id="itemBrands">
@foreach (var item in items.DistinctBy(item => item.Brand))
{
<option value="@item.Brand" />
}
</datalist>
<datalist id="itemNames">
@foreach (var item in items.OrderBy(item => item.Name))
{
<option value="@item.Name" data-list-filter-target="option" data-list-filter-value="@item.Brand" />
}
</datalist>
</div>
</fieldset>
<div class="form-field">
<label class="form-field__label" for="transactionItemPrice">Price</label>
<div class="form-field__control input">
@*<span class="input__inset">@CultureInfo.CurrentCulture.NumberFormat.CurrencySymbol</span>*@
<input class="input__control" id="transactionItemPrice" name="price" value="@Model?.Price" type="number" min="0" step="0.01" required />
</div>
</div>
<div class="form-field">
<label class="form-field__label" for="transactionItemQuantity">Quantity</label>
<div class="form-field__control input">
<input class="input__control" id="transactionItemQuantity" name="quantity" value="@(Model?.Quantity ?? 1)" type="number" min="1" required />
</div>
</div>

View File

@ -0,0 +1,3 @@
@using Groceries
@namespace Groceries.Views
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -0,0 +1,3 @@
@{
Layout = ViewBag.RenderingToTurboStream == true ? null : "_Layout";
}

5
Groceries/config.ini Normal file
View File

@ -0,0 +1,5 @@
Database="Host=127.0.0.1;Username=groceries;Password=password;Database=groceries"
[Logging:LogLevel]
Default=Information
Microsoft=Warning

14
Groceries/libman.json Normal file
View File

@ -0,0 +1,14 @@
{
"version": "1.0",
"defaultProvider": "unpkg",
"libraries": [
{
"library": "@hotwired/turbo@7.3.0",
"destination": "wwwroot/lib/hotwired/turbo/"
},
{
"library": "@hotwired/stimulus@3.2.1",
"destination": "wwwroot/lib/hotwired/stimulus/"
}
]
}

View File

@ -0,0 +1,644 @@
@import url("https://rsms.me/inter/inter.css");
:root {
font-family: "Inter", sans-serif;
color-scheme: light;
}
@supports (font-variation-settings: normal) {
:root {
font-family: "Inter var", sans-serif;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
body {
display: flex;
background-color: rgb(243, 244, 246);
}
h1 {
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.025em;
margin-bottom: 1rem;
}
h1, h2, h3, h4, h5, h6 {
color: rgb(17, 24, 39);
}
/*@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
}
body {
background-color: rgb(17, 24, 39);
}
h1, h2, h3, h4, h5, h6 {
color: rgb(255, 255, 255);
}
}*/
.main-content {
height: 100vh;
width: 100%;
max-width: 80rem;
overflow-y: auto;
padding: 2.5rem 4rem;
margin: 0 auto;
background-color: inherit;
}
@media (max-width: 40rem) {
.main-content {
padding-inline: 0;
}
}
/* Layout */
.flex-2 {
flex: 2;
}
.flex-5 {
flex: 5;
}
.row {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.row__fill {
flex: auto;
}
/* Icon */
.icon {
fill: currentColor;
height: 1.5rem;
width: 1.5rem;
}
.icon--sm {
height: 1.25rem;
width: 1.25rem;
}
/* Sidebar */
.sidebar {
flex: none;
height: 100vh;
width: 16rem;
padding: 0.5rem;
display: flex;
flex-direction: column;
border-right: 1px solid rgb(229, 231, 235);
background-color: rgb(255, 255, 255);
}
.sidebar__toggle, .sidebar__toggle + label {
display: none;
}
@media (max-width: 40rem) {
.sidebar {
position: fixed;
transform: translateX(-100%);
transition: transform 0.5s cubic-bezier(0.19, 1, 0.22, 1);
will-change: transform;
}
.sidebar__toggle:checked ~ .sidebar {
transform: translateX(0);
}
.sidebar__toggle {
display: initial;
position: fixed;
visibility: hidden;
}
.sidebar__toggle + label {
background: none;
border: none;
outline: none;
cursor: pointer;
display: initial;
position: absolute;
transform: translateX(0);
transition: transform 0.5s cubic-bezier(0.19, 1, 0.22, 1);
will-change: transform;
z-index: 2;
}
.sidebar__toggle:checked + label {
transform: translateX(16rem);
}
.sidebar__toggle + label::before {
content: "";
pointer-events: none;
position: fixed;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.2);
opacity: 0;
transition: opacity 0.5s cubic-bezier(0.19, 1, 0.22, 1);
will-change: opacity;
z-index: 1;
}
.sidebar__toggle:checked + label::before {
opacity: 100%;
pointer-events: auto;
}
.main-content {
transform: translateX(0);
transition: transform 0.5s cubic-bezier(0.19, 1, 0.22, 1);
will-change: transform;
}
.sidebar__toggle:checked ~ .main-content {
transform: translateX(16rem);
}
}
.sidebar__header {
color: rgb(17, 24, 39);
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.025em;
padding: 1.5rem 0.75rem;
}
.sidebar__body {
flex: 1;
}
.sidebar__item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.5rem;
border-radius: 0.375rem;
color: rgb(107, 114, 128);
font-weight: 500;
}
.sidebar__item--active {
background-color: rgb(243, 244, 246);
color: rgb(75, 85, 99);
}
.sidebar__link {
color: inherit;
text-decoration: none;
}
/*@media (prefers-color-scheme: dark) {
.sidebar {
border-color: rgb(75, 85, 99);
background-color: rgb(31, 41, 55);
}
.sidebar__header {
color: rgb(255, 255, 255);
}
.sidebar__item {
color: rgb(209, 213, 219);
}
.sidebar__item:where(:hover) {
background-color: rgb(55, 65, 81);
color: rgb(255, 255, 255);
}
.sidebar__item--active {
background-color: rgb(17, 24, 39);
color: rgb(255, 255, 255);
}
}*/
/* Modal */
.modal {
background-color: transparent;
border: none;
overflow: visible;
margin: auto;
min-width: min(28rem, 100%);
}
.modal::backdrop {
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
}
html:has(.modal[open]) {
overflow: hidden;
}
/* HACK: should probably be a .button--icon */
.modal__close-button {
padding: 0 !important;
margin-block: -1rem;
width: 2rem;
height: 2rem;
}
/* Slide toggle */
.slide-toggle {
padding: 0.125rem;
border-radius: 0.5rem;
background-color: rgb(243, 244, 246);
}
.slide-toggle__option {
display: inline-flex;
align-items: center;
}
.slide-toggle__control {
appearance: none;
}
/*@media (prefers-color-scheme: dark) {
.slide-toggle {
background-color: rgb(17, 24, 39);
}
}*/
/* Card */
.card {
overflow: hidden;
background-color: rgb(255, 255, 255);
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.card__content {
margin: 1.5rem;
}
.card__header {
border-bottom: 1px solid rgb(243, 244, 246);
padding: 1.25rem 1.5rem;
}
.card__footer {
border-top: 1px solid rgb(243, 244, 246);
padding: 0.75rem 1.5rem;
}
.card__footer--shaded {
background-color: rgb(249, 250, 251);
border-color: transparent;
}
/* Table */
.table {
overflow: hidden;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.table table {
min-width: 100%;
border-collapse: collapse;
}
.table__header {
background-color: rgb(249, 250, 251);
color: rgb(107, 114, 128);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.05em;
line-height: 1rem;
text-align: start;
text-transform: uppercase;
padding: 0.75rem 1.5rem;
white-space: nowrap;
}
.table__header--sortable a {
color: inherit;
text-decoration: none;
position: relative;
}
.table__header--sortable a::after {
position: absolute;
margin-left: 0.3rem;
}
.table__header--sortable a[data-dir=asc]::after {
content: "\25B2";
}
.table__header--sortable a[data-dir=desc]::after {
content: "\25BC";
}
.table__cell {
background-color: rgb(255, 255, 255);
color: rgb(17, 24, 39);
border-top: 1px solid rgb(229, 231, 235);
font-size: 0.875rem;
line-height: 1.25rem;
padding: 1rem 1.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table__cell--icon {
font-size: 1.25rem;
}
.table__cell--numeric {
text-align: end;
}
.table__paginator {
display: flex;
justify-content: space-between;
background-color: rgb(255, 255, 255);
color: rgb(55, 65, 81);
border-top: 1px solid rgb(229, 231, 235);
font-size: 0.875rem;
line-height: 1.25rem;
padding: 0.75rem 1.5rem;
}
/*@media (prefers-color-scheme: dark) {
.table__header {
background-color: rgb(55, 65, 81);
color: rgb(156, 163, 175);
}
.table__cell {
background-color: rgb(31, 41, 55);
border-color: rgb(55, 65, 81);
color: rgb(255, 255, 255);
}
.table__paginator {
background-color: rgb(55, 65, 81);
border-color: rgb(55, 65, 81);
color: rgb(156, 163, 175);
}
}*/
/* Link */
.link {
display: inline-block;
text-decoration: none;
appearance: none;
font-weight: 600;
color: rgb(26, 86, 219);
cursor: pointer;
}
.link:focus-visible:not(.link--disabled, [disabled]), .link:hover {
outline: none;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 2px;
}
.link--disabled, .link[disabled] {
pointer-events: none;
opacity: 50%;
}
/* Button */
.button {
display: inline-block;
text-decoration: none;
appearance: none;
background-color: rgb(255, 255, 255);
border: 1px solid rgb(209, 213, 219);
border-radius: 0.375rem;
color: rgb(55, 65, 81);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
padding: 0.5rem 1rem;
cursor: pointer;
}
.button--primary {
background-color: rgb(26, 86, 219);
border-color: transparent;
color: rgb(255, 255, 255);
}
.button--danger {
background-color: rgb(220, 38, 38);
border-color: transparent;
color: rgb(255, 255, 255);
}
.button:focus-visible:not(.button--disabled, [disabled]), .button:hover {
outline: none;
filter: brightness(0.85);
}
.button--disabled, .button[disabled] {
pointer-events: none;
opacity: 50%;
}
.button-group {
display: flex;
gap: 1rem;
}
/* Form field */
.form-field {
border: none;
margin-bottom: 1.5rem;
}
.form-field__label {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
color: rgb(55, 65, 81);
}
.form-field__control {
flex: auto;
position: relative;
margin-top: 0.25rem;
border-radius: 0.375rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.form-field__corner-hint {
font-size: 0.65rem;
line-height: 1rem;
text-transform: uppercase;
}
/* Input */
.input {
display: flex;
position: relative;
}
.input__control {
appearance: none;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-family: inherit;
font-size: 0.875rem;
line-height: 1.25rem;
text-overflow: ellipsis;
background-color: rgb(255, 255, 255);
border: 1px solid rgb(209, 213, 219);
z-index: 1;
}
.input__control[list]::-webkit-calendar-picker-indicator {
display: none !important;
}
/*
.input--leading-addon-sm .input__control {
padding-left: 1.75rem;
}
*/
.input__inset + .input__control {
padding-left: 2.25rem;
}
.input__control:has(+ .input__control), .input__control:has(+ .input__addon:not([hidden])) {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
.input__control:focus {
outline: 2px solid rgb(63, 131, 248);
outline-offset: -1px;
z-index: 2;
}
.input__control::placeholder {
opacity: 1;
color: rgb(156, 163, 175);
}
.input__inset {
display: flex;
align-items: center;
color: rgb(107, 114, 128);
font-size: 0.875rem;
line-height: 1.25rem;
pointer-events: none;
padding: 0 0.75rem;
position: absolute;
height: 100%;
z-index: 3;
}
.input__addon {
color: rgb(107, 114, 128);
background-color: rgb(249, 250, 251);
border-color: rgb(209, 213, 219);
}
.input__control + .input__control, .input__control ~ .input__addon {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
border-left-width: 0;
}
/*@media (prefers-color-scheme: dark) {
.input__control {
background-color: rgb(55, 65, 81);
border-color: rgb(75, 85, 99);
}
.input__addon {
color: rgb(156, 163, 175);
}
}*/
/* Text area */
.textarea {
appearance: none;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-family: inherit;
font-size: 0.875rem;
line-height: 1.25rem;
background-color: rgb(255, 255, 255);
border: 1px solid rgb(209, 213, 219);
}
.textarea:focus {
outline: 1px solid rgb(63, 131, 248);
border-color: rgb(63, 131, 248);
}
.textarea::placeholder {
opacity: 1;
color: rgb(156, 163, 175);
}
/* Select */
.select {
appearance: none;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-family: inherit;
font-size: 0.875rem;
line-height: 1.25rem;
background-color: rgb(255, 255, 255);
border: 1px solid rgb(209, 213, 219);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-size: 1.5rem 1.5rem;
background-position: right 0.5rem center;
}
.select:focus {
outline: 1px solid rgb(63, 131, 248);
border-color: rgb(63, 131, 248);
}

View File

@ -0,0 +1,17 @@
import { Controller } from "/lib/hotwired/stimulus/dist/stimulus.js";
export default class ListFilterController extends Controller {
static targets = ["option"];
filter(event) {
for (const option of this.optionTargets) {
if (!event.target.value) {
option.disabled = false;
continue;
}
const value = option.getAttribute("data-list-filter-value");
option.disabled = value !== event.target.value;
}
}
}

View File

@ -0,0 +1,33 @@
import { Controller } from "/lib/hotwired/stimulus/dist/stimulus.js";
export default class ModalController extends Controller {
static targets = ["frame"];
open() {
if (!this.element.open) {
this.element.showModal();
}
}
close(event) {
if (!this.element.open) {
return;
}
event.preventDefault();
this.element.close();
this.frameTarget.src = undefined;
this.frameTarget.innerHTML = "";
switch (event.type) {
case "turbo:submit-end":
Turbo.visit(location.href, { action: "replace" });
break;
case "popstate":
event.stopImmediatePropagation();
history.go(1);
break;
}
}
}

View File

@ -0,0 +1,21 @@
import { Controller } from "/lib/hotwired/stimulus/dist/stimulus.js";
export default class SearchFormController extends Controller {
static targets = ["button"];
#timeoutHandle;
connect() {
this.buttonTarget.hidden = true;
}
disconnect() {
clearTimeout(this.#timeoutHandle);
this.buttonTarget.hidden = false;
}
input() {
clearTimeout(this.#timeoutHandle);
this.#timeoutHandle = setTimeout(() => this.element.requestSubmit(), 500);
}
}

View File

@ -0,0 +1,37 @@
import { Application } from "/lib/hotwired/stimulus/dist/stimulus.js";
import ListFilterController from "./controllers/list-filter.js";
import ModalController from "./controllers/modal.js";
import SearchFormController from "./controllers/search-form.js";
const app = Application.start();
app.register("list-filter", ListFilterController);
app.register("modal", ModalController);
app.register("search-form", SearchFormController);
let timeout;
document.addEventListener("turbo:visit", () => {
clearTimeout(timeout);
});
document.addEventListener("turbo:render", () => {
clearTimeout(timeout);
if (document.getElementById("sidebarToggle").checked) {
timeout = setTimeout(() => document.getElementById("sidebarToggle").checked = false, 500);
}
});
let transition;
document.addEventListener("turbo:before-render", async event => {
if (document.startViewTransition) {
event.preventDefault();
if (transition == undefined) {
transition = document.startViewTransition(() => event.detail.resume());
await transition.finished;
transition = undefined;
} else {
await transition.finished;
event.detail.resume();
}
}
});