Compare commits

...

25 Commits

Author SHA1 Message Date
ba5766f9d5 Update to .NET 10 preview
All checks were successful
Docker Image CI / build (push) Successful in 8m8s
2025-03-11 23:32:27 +00:00
25876813e5 Update dependencies 2025-03-11 22:21:20 +00:00
680458b0f3 Update dependencies
All checks were successful
Docker Image CI / build (push) Successful in 4m20s
2024-12-30 13:33:49 +00:00
79850c46b5 Tweak New Transaction - Items page styles so content fits on narrow screens
All checks were successful
Docker Image CI / build (push) Successful in 3m5s
2024-10-14 18:26:07 +01:00
e5478a426b Fix last purchased quantity not set when adding loose item 2024-10-14 15:09:54 +01:00
d089065208 Fix format of default item quantity when scanning barcode 2024-10-14 15:08:46 +01:00
d64870bf4f Fix FOUT causing layout shift on full page load
All checks were successful
Docker Image CI / build (push) Successful in 3m5s
2024-10-13 16:45:16 +01:00
00e17497bf Fix error saving transaction with a promotion with items 2024-10-13 16:17:35 +01:00
2c0f6f1cab Fix error loading Add/Edit Transaction Item page
All checks were successful
Docker Image CI / build (push) Successful in 3m8s
2024-10-12 16:00:32 +01:00
47d13ba922 Add support for adding 'loose' items to transactions
All checks were successful
Docker Image CI / build (push) Successful in 3m45s
2024-10-12 02:36:38 +01:00
dfcab40d70 Fix CSS/JS not resolving on full-page reload for New Transaction pages 2024-10-06 21:19:55 +01:00
eee2c201fa Store when barcode was last scanned
All checks were successful
Docker Image CI / build (push) Successful in 14m3s
2024-10-05 19:15:21 +01:00
68eff11fdc Update dependencies 2024-10-05 17:55:58 +01:00
485f58c61d Serve fonts locally 2024-10-05 17:41:24 +01:00
8669904edd Use new static assets middleware
All checks were successful
Docker Image CI / build (push) Successful in 2m8s
2024-07-12 00:45:34 +01:00
2f5212f8d0 Update to .NET 9 preview 2024-07-11 22:47:19 +01:00
8066478447 Simplify copying csproj files in Docker build
All checks were successful
Docker Image CI / build (push) Successful in 1m41s
2024-07-11 21:46:55 +01:00
4c62cded56 Enable X-Forwarded-* headers using env var instead of code 2024-07-11 21:45:04 +01:00
6abff69bcb Fix transaction item modal closing when scanning barcode
All checks were successful
Docker Image CI / build (push) Successful in 2m26s
2024-07-11 21:07:04 +01:00
a48df4e2da Disable page prefetching 2024-07-11 19:46:28 +01:00
7fcf038f3d Update dependencies
All checks were successful
Docker Image CI / build (push) Successful in 1m46s
2024-07-10 23:20:26 +01:00
0ae990e6c0 Reenable workflow caching
All checks were successful
Docker Image CI / build (push) Successful in 1m46s
2024-07-10 19:10:30 +01:00
f39f48b882 Update workflow actions 2024-07-10 19:08:32 +01:00
db0dd88386 Update README with new image URL
All checks were successful
Docker Image CI / build (push) Successful in 1m53s
2024-07-07 23:47:41 +01:00
01f8e8c1d6 Update workflow for Gitea 2024-07-07 23:42:39 +01:00
41 changed files with 431 additions and 206 deletions

View File

@ -10,38 +10,34 @@ jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
- name: Login to Container Registry
if: gitea.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{secrets.GITHUB_TOKEN}}
registry: git.jamsch0.dev
username: ${{gitea.actor}}
password: ${{secrets.CI_TOKEN}}
- name: Extract metadata
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/jamsch0/groceries
images: git.jamsch0.dev/jamsch0/groceries
tags: type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
context: .
cache-from: type=gha
cache-to: type=gha,mode=max
push: ${{github.event_name != 'pull_request'}}
push: ${{gitea.event_name != 'pull_request'}}
tags: ${{steps.meta.outputs.tags}}
labels: ${{steps.meta.outputs.labels}}

View File

@ -1,4 +1,6 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build1
# syntax=docker/dockerfile:1.7-labs
FROM mcr.microsoft.com/dotnet/sdk:10.0-preview-alpine AS build1
WORKDIR /src
COPY ./.config ./
@ -8,21 +10,20 @@ WORKDIR Groceries
COPY ./Groceries/libman.json ./
RUN dotnet libman restore
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build2
FROM mcr.microsoft.com/dotnet/sdk:10.0-preview-alpine AS build2
WORKDIR /src
COPY ./Groceries.sln ./
COPY ./Directory.Build.props ./
COPY */*.csproj ./
RUN for file in $(ls *.csproj); do mkdir -p ${file%.*} && mv $file ${file%.*}; done
COPY --parents */*.csproj .
RUN dotnet restore
COPY . ./
COPY --from=build1 /src ./
RUN dotnet publish --no-restore --output /out
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine-composite AS base
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview-alpine-composite AS base
WORKDIR /groceries
COPY --from=build2 /out .
@ -30,6 +31,7 @@ COPY --from=build2 /src/Groceries/config.ini /config/
RUN apk add --no-cache icu-libs tzdata
ENV ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
ENV ASPNETCORE_HTTP_PORTS=80
ENV DOTNET_ENABLEDIAGNOSTICS=0
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false

View File

@ -33,6 +33,9 @@ public class AppDbContext : DbContext
entity.Property(e => e.Format)
.HasDefaultValueSql();
entity.Property(e => e.LastScannedAt)
.HasDefaultValueSql();
});
modelBuilder.Entity<ItemPurchase>(entity =>

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
@ -9,10 +9,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DbUp-PostgreSQL" Version="5.0.37" />
<PackageReference Include="EFCore.NamingConventions" Version="8.0.2" />
<PackageReference Include="DbUp-PostgreSQL" Version="6.0.3" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0-preview.1" />
</ItemGroup>
<ItemGroup>

View File

@ -12,4 +12,5 @@ public class ItemBarcode
public Guid ItemId { get; init; }
public long BarcodeData { get; init; }
public string Format { get; init; }
public DateTime LastScannedAt { get; set; }
}

View File

@ -7,7 +7,8 @@ public class ItemPurchase
public DateTime CreatedAt { get; init; }
public Guid StoreId { get; init; }
public decimal Price { get; init; }
public int Quantity { get; init; }
public decimal Quantity { get; init; }
public string? Unit { get; init; }
public bool IsLastPurchase { get; init; }
public Item? Item { get; init; }

View File

@ -0,0 +1,7 @@
ALTER TABLE item_barcodes
ADD COLUMN IF NOT EXISTS last_scanned_at timestamptz NOT NULL DEFAULT current_timestamp;
UPDATE item_barcodes
SET last_scanned_at = created_at
FROM item_purchases
WHERE item_barcodes.item_id = item_purchases.item_id AND is_last_purchase = true;

View File

@ -0,0 +1,35 @@
DROP VIEW item_purchases;
DROP VIEW transaction_totals;
ALTER TABLE transaction_items
ALTER COLUMN quantity TYPE numeric(5, 3);
ALTER TABLE transaction_items
ADD COLUMN IF NOT EXISTS unit text;
CREATE VIEW item_purchases AS
SELECT
item_id,
transaction_id,
created_at,
store_id,
price,
quantity,
unit,
CASE ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY created_at DESC)
WHEN 1 THEN true
ELSE false
END AS is_last_purchase
FROM transaction_items
JOIN transactions USING (transaction_id);
CREATE VIEW transaction_totals AS
SELECT transaction_id, sum(amount) AS total
FROM (
SELECT transaction_id, price * quantity AS amount
FROM transaction_items
UNION ALL
SELECT transaction_id, -amount
FROM transaction_promotions
) AS transaction_amounts
GROUP BY transaction_id;

View File

@ -5,22 +5,24 @@ using System.Text.Json.Serialization;
public class TransactionItem
{
[JsonConstructor]
public TransactionItem(Guid transactionId, Guid itemId, decimal price, int quantity)
public TransactionItem(Guid transactionId, Guid itemId, decimal price, decimal quantity, string? unit)
{
TransactionId = transactionId;
ItemId = itemId;
Price = price;
Quantity = quantity;
Unit = unit;
}
public TransactionItem(Guid itemId, decimal price, int quantity) : this(default, itemId, price, quantity)
public TransactionItem(Guid itemId, decimal price, decimal quantity, string? unit) : this(default, itemId, price, quantity, unit)
{
}
public Guid TransactionId { get; init; }
public Guid ItemId { get; set; }
public decimal Price { get; set; }
public int Quantity { get; set; }
public decimal Quantity { get; set; }
public string? Unit { get; set; }
public Item? Item { get; set; }

View File

@ -6,11 +6,19 @@
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="view-transition" content="same-origin" />
<meta name="turbo-prefetch" content="false" />
<link rel="stylesheet" type="text/css" href="/css/main.css" asp-append-version="true" data-turbo-track="reload" />
<base href="/" />
<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>
<link rel="preload" as="font" type="font/woff2" href="lib/inter/files/inter-latin-wght-normal.woff2" crossorigin />
<link rel="stylesheet" type="text/css" href="@Assets["lib/inter/index.css"]" data-turbo-track="reload" />
<link rel="stylesheet" type="text/css" href="@Assets["css/main.css"]" data-turbo-track="reload" />
<ImportMap />
<script type="module" src="@Assets["js/main.js"]" data-turbo-track="reload"></script>
<script type="module" src="@Assets["lib/hotwired/turbo/dist/turbo.es2017-esm.js"]"></script>
<HeadOutlet />
</head>

View File

@ -20,6 +20,9 @@ public class PropertyTableColumn<TItem, TProp> : TableColumn<TItem>
[Parameter]
public string? Format { get; set; }
[Parameter]
public Func<TItem, string>? CompositeFormat { get; set; }
[Parameter]
public override bool Sortable { get; set; }
@ -55,7 +58,11 @@ public class PropertyTableColumn<TItem, TProp> : TableColumn<TItem>
if (ChildContent == null)
{
if (!string.IsNullOrEmpty(Format) &&
if (CompositeFormat != null)
{
cellTextFunc = item => string.Format(CompositeFormat(item), compiledPropertyExpression(item));
}
else if (!string.IsNullOrEmpty(Format) &&
typeof(IFormattable).IsAssignableFrom(Nullable.GetUnderlyingType(typeof(TProp)) ?? typeof(TProp)))
{
cellTextFunc = item => ((IFormattable?)compiledPropertyExpression(item))?.ToString(Format, null);

View File

@ -176,6 +176,12 @@
{
string?[] classes = [
"table__header",
column.Align switch
{
Align.Center => "table__header--align-center",
Align.End => "table__header--align-end",
_ => null,
},
column.Sortable ? "table__header--sortable" : null,
HeaderClass,
];

View File

@ -4,7 +4,7 @@
<span>
Showing @FirstItem to @LastItem of @State.TotalItemCount results
</span>
<nav class="button-group">
<nav>
@if (State.CurrentPage == 1)
{
<span class="link link--disabled">Previous</span>

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>

View File

@ -4,7 +4,7 @@
@layout Layout
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
@inject IHttpContextAccessor HttpContextAccessor
<HeadContent>
@ -52,7 +52,8 @@
protected override async Task OnInitializedAsync()
{
model = await DbContext.ItemTagQuantities
using var dbContext = DbContextFactory.CreateDbContext();
model = await dbContext.ItemTagQuantities
.FromSqlRaw(@"
SELECT tag, quantity, coalesce(unit_name, unit) AS unit, is_metric, is_divisible
FROM (

View File

@ -32,10 +32,10 @@ public static class HttpRequestExtensions
return origin.IsBaseOf(uri);
}
public static Uri? GetRefererIfSameOrigin(this HttpRequest request)
public static Uri? GetReferrerIfSameOrigin(this HttpRequest request)
{
var referer = request.GetTypedHeaders().Referer;
return referer != null && request.IsSameOrigin(referer) ? referer : null;
var referrer = request.GetTypedHeaders().Referer;
return referrer != null && request.IsSameOrigin(referrer) ? referrer : null;
}
public static bool IsTurboFrameRequest(this HttpRequest request, string frameId)

View File

@ -2,7 +2,9 @@
@using Microsoft.EntityFrameworkCore
@layout Layout
@inject AppDbContext DbContext
@implements IDisposable
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; Items</PageTitle>
@ -44,6 +46,7 @@
public DateTime? LastPurchasedAt { get; init; }
}
private AppDbContext? dbContext;
private IQueryable<ItemModel> items = null!;
private PaginationState pagination = new();
@ -52,7 +55,9 @@
protected override void OnParametersSet()
{
var itemsQuery = DbContext.Items.AsQueryable();
dbContext ??= DbContextFactory.CreateDbContext();
var itemsQuery = dbContext.Items.AsQueryable();
if (!string.IsNullOrEmpty(Search))
{
var searchPattern = $"%{Search}%";
@ -60,22 +65,24 @@
}
items = itemsQuery
.GroupJoin(
DbContext.ItemPurchases.Where(purchase => purchase.IsLastPurchase),
.LeftJoin(
dbContext.ItemPurchases.Where(purchase => purchase.IsLastPurchase),
item => item.Id,
purchase => purchase.ItemId,
(item, purchases) => new { item, purchases })
.SelectMany(
group => group.purchases.DefaultIfEmpty(),
(group, lastPurchase) => new ItemModel
(item, lastPurchase) => new ItemModel
{
Id = group.item.Id,
Brand = group.item.Brand,
Name = group.item.Name,
HasBarcode = group.item.Barcodes.Count != 0,
Id = item.Id,
Brand = item.Brand,
Name = item.Name,
HasBarcode = item.Barcodes.Count > 0,
LastPurchasedAt = lastPurchase != null ? lastPurchase.CreatedAt : null,
})
.OrderBy(item => item.Brand)
.ThenBy(item => item.Name);
}
public void Dispose()
{
dbContext?.Dispose();
}
}

View File

@ -1,7 +1,7 @@
using DbUp;
using DbUp.Engine.Output;
using Groceries.Data;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
@ -14,27 +14,6 @@ builder.Configuration
.AddIniFile(Path.Combine(dataDir, $"config_{env.EnvironmentName}.ini"), optional: true, reloadOnChange: true);
var dbConn = builder.Configuration["Database"]!;
EnsureDatabase.For.PostgresqlDatabase(dbConn);
var dbUpgradeResult = DeployChanges.To
.PostgresqlDatabase(dbConn)
.JournalToPostgresqlTable("public", "__dbup_migrations")
.WithScriptsEmbeddedInAssembly(typeof(AppDbContext).Assembly)
.WithTransactionPerScript()
.Build()
.PerformUpgrade();
if (!dbUpgradeResult.Successful)
{
return -1;
}
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.All;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
var dataProtection = builder.Services.AddDataProtection();
if (env.IsProduction())
@ -50,7 +29,7 @@ builder.Services.AddDistributedMemoryCache();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSession();
builder.Services.AddDbContextPool<AppDbContext>(options => options
builder.Services.AddPooledDbContextFactory<AppDbContext>(options => options
.EnableDetailedErrors(env.IsDevelopment())
.EnableSensitiveDataLogging(env.IsDevelopment())
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)
@ -59,13 +38,29 @@ builder.Services.AddDbContextPool<AppDbContext>(options => options
var app = builder.Build();
app.UseForwardedHeaders();
app.UseStaticFiles();
app.UseRouting();
app.UseSession();
app.MapControllers();
app.MapStaticAssets();
app.MapControllers()
.WithStaticAssets();
await app.RunAsync();
var dbUpgradeLogger = new MicrosoftUpgradeLog(app.Logger);
EnsureDatabase.For.PostgresqlDatabase(dbConn, dbUpgradeLogger);
return 0;
var dbUpgradeResult = DeployChanges.To
.PostgresqlDatabase(dbConn)
.JournalToPostgresqlTable("public", "__dbup_migrations")
.WithScriptsEmbeddedInAssembly(typeof(AppDbContext).Assembly)
.WithTransactionPerScript()
.LogTo(dbUpgradeLogger)
.Build()
.PerformUpgrade();
if (!dbUpgradeResult.Successful)
{
Environment.Exit(-1);
return;
}
app.Run();

View File

@ -24,9 +24,9 @@
protected override void OnInitialized()
{
var request = HttpContextAccessor.HttpContext!.Request;
if (request.GetRefererIfSameOrigin() is Uri referer && referer != request.GetUri())
if (request.GetReferrerIfSameOrigin() is Uri referrer && referrer != request.GetUri())
{
returnUrl = referer.PathAndQuery;
returnUrl = referrer.PathAndQuery;
}
}
}

View File

@ -19,9 +19,9 @@
protected override void OnInitialized()
{
var request = HttpContextAccessor.HttpContext!.Request;
if (request.GetRefererIfSameOrigin() is Uri referer && referer != request.GetUri())
if (request.GetReferrerIfSameOrigin() is Uri referrer && referrer != request.GetUri())
{
returnUrl = referer.PathAndQuery;
returnUrl = referrer.PathAndQuery;
}
}
}

View File

@ -1,7 +1,7 @@
@using Groceries.Data
@using Microsoft.EntityFrameworkCore
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
<form method="post" @attributes="AdditionalAttributes">
<div class="form-field">
@ -45,7 +45,8 @@
protected override async Task OnInitializedAsync()
{
retailers = await DbContext.Retailers
using var dbContext = DbContextFactory.CreateDbContext();
retailers = await dbContext.Retailers
.OrderBy(retailer => retailer.Name)
.ToArrayAsync();
}

View File

@ -8,11 +8,11 @@ using Microsoft.EntityFrameworkCore;
[Route("/stores")]
public class StoresController : Controller
{
private readonly AppDbContext dbContext;
private readonly IDbContextFactory<AppDbContext> dbContextFactory;
public StoresController(AppDbContext dbContext)
public StoresController(IDbContextFactory<AppDbContext> dbContextFactory)
{
this.dbContext = dbContext;
this.dbContextFactory = dbContextFactory;
}
[HttpGet]
@ -32,6 +32,8 @@ public class StoresController : Controller
[HttpPost("new")]
public async Task<IResult> NewStore(Guid retailerId, string name, string? address)
{
using var dbContext = dbContextFactory.CreateDbContext();
var store = new Store(retailerId, name, address);
dbContext.Stores.Add(store);
@ -45,6 +47,8 @@ public class StoresController : Controller
[HttpGet("edit/{id}")]
public async Task<IResult> EditStore(Guid id)
{
using var dbContext = dbContextFactory.CreateDbContext();
var store = await dbContext.Stores
.SingleOrDefaultAsync(store => store.Id == id, HttpContext.RequestAborted);
@ -61,6 +65,8 @@ public class StoresController : Controller
[HttpPost("edit/{id}")]
public async Task<IResult> EditStore(Guid id, Guid retailerId, string name, string? address, string? returnUrl)
{
using var dbContext = dbContextFactory.CreateDbContext();
var store = new Store(id, retailerId, name, address);
dbContext.Stores.Update(store);

View File

@ -2,7 +2,9 @@
@using Microsoft.EntityFrameworkCore
@layout Layout
@inject AppDbContext DbContext
@implements IDisposable
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; Stores</PageTitle>
@ -39,6 +41,7 @@
public int TransactionsCount { get; init; }
}
private AppDbContext? dbContext;
private IQueryable<StoreModel> stores = null!;
private PaginationState pagination = new();
@ -47,7 +50,9 @@
protected override void OnParametersSet()
{
var storesQuery = DbContext.Stores.AsQueryable();
dbContext ??= DbContextFactory.CreateDbContext();
var storesQuery = dbContext.Stores.AsQueryable();
if (!string.IsNullOrEmpty(Search))
{
var searchPattern = $"%{Search}%";
@ -66,4 +71,9 @@
.OrderBy(store => store.Retailer)
.ThenBy(store => store.Name);
}
public void Dispose()
{
dbContext?.Dispose();
}
}

View File

@ -3,7 +3,7 @@
@layout Layout
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; Edit Transaction Item</PageTitle>
@ -19,7 +19,7 @@
<div class="row">
<button class="button button--primary" type="submit" form="editTransactionItem">Update</button>
<a class="button" href="/transaction/new/items">Cancel</a>
<a class="button" href="/transactions/new/items">Cancel</a>
<span class="row__fill"></span>
<button class="button button--danger" type="submit" form="deleteTransactionItem">Remove</button>
</div>
@ -35,7 +35,8 @@
protected override async Task OnParametersSetAsync()
{
store = await DbContext.Stores
using var dbContext = DbContextFactory.CreateDbContext();
store = await dbContext.Stores
.Where(store => store.Id == Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync();

View File

@ -3,7 +3,7 @@
@layout Layout
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; Edit Transaction Promotion</PageTitle>
@ -35,7 +35,8 @@
protected override async Task OnParametersSetAsync()
{
store = await DbContext.Stores
using var dbContext = DbContextFactory.CreateDbContext();
store = await dbContext.Stores
.Where(store => store.Id == Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync();

View File

@ -16,5 +16,5 @@
public required Transaction Transaction { get; set; }
[Parameter]
public TransactionItem? TransactionItem { get; set; }
public required TransactionItem TransactionItem { get; set; }
}

View File

@ -3,7 +3,7 @@
@layout Layout
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; New Transaction Item</PageTitle>
@ -16,7 +16,7 @@
<TransactionItemForm TransactionItem="TransactionItem">
<div class="row">
<button class="button button--primary" type="submit">Add</button>
<a class="button" href="/transaction/new/items">Cancel</a>
<a class="button" href="/transactions/new/items">Cancel</a>
</div>
</TransactionItemForm>
@ -25,13 +25,14 @@
public required Transaction Transaction { get; set; }
[Parameter]
public TransactionItem? TransactionItem { get; set; }
public required TransactionItem TransactionItem { get; set; }
private string store = string.Empty;
protected override async Task OnParametersSetAsync()
{
store = await DbContext.Stores
using var dbContext = DbContextFactory.CreateDbContext();
store = await dbContext.Stores
.Where(store => store.Id == Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync();

View File

@ -2,7 +2,8 @@
@using Microsoft.EntityFrameworkCore
@layout Layout
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; New Transaction</PageTitle>
@ -16,19 +17,29 @@
<section class="card form-field">
<div class="card__header row">
<h2 class="row__fill">Items</h2>
<a class="button button--primary" href="/transactions/new/items/new" autofocus data-turbo-frame="modal">
New item
</a>
<div class="button-group dropdown">
<a class="button button--primary" href="/transactions/new/items/new" autofocus data-turbo-frame="modal">
New item
</a>
<button class="button button--primary dropdown__toggle" type="button" popovertarget="newItemMenu"></button>
<ul class="dropdown__menu" id="newItemMenu" popover>
<li>
<a class="button dropdown__item" href="/transactions/new/items/new?unit=kg" data-turbo-frame="modal">
New loose item
</a>
</li>
</ul>
</div>
</div>
<div class="card__content card__content--table">
<Table Items="Transaction.Items.AsQueryable()" CellClass="table__cell--compact">
<Table Items="Transaction.Items.AsQueryable()" HeaderClass="table__header--compact" CellClass="table__cell--compact">
<ChildContent>
<TemplateTableColumn Title="Name" Fill="true" Context="item">
@itemNames.GetValueOrDefault(item.ItemId)
<div class="line-clamp-4">@itemNames.GetValueOrDefault(item.ItemId)</div>
</TemplateTableColumn>
<PropertyTableColumn Property="i => i.Price" Format="c" />
<PropertyTableColumn Property="i => i.Quantity">
<PropertyTableColumn Property="i => i.Price" CompositeFormat='i => i.Unit == null ? "{0:c}" : ("{0:c}/" + i.Unit)' />
<PropertyTableColumn Property="i => i.Quantity" CompositeFormat='i => i.Unit == null ? "{0:f0}" : ("{0:f3}" + i.Unit)'>
<HeaderContent>
<abbr title="Quantity">Qty</abbr>
</HeaderContent>
@ -68,13 +79,15 @@
protected override async Task OnParametersSetAsync()
{
store = await DbContext.Stores
using var dbContext = DbContextFactory.CreateDbContext();
store = await dbContext.Stores
.Where(store => store.Id == Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync();
var itemIds = Transaction.Items.Select(item => item.ItemId);
itemNames = await DbContext.Items
itemNames = await dbContext.Items
.Where(item => itemIds.Contains(item.Id))
.ToDictionaryAsync(item => item.Id, item => string.Concat(item.Brand, " ", item.Name));
}

View File

@ -3,7 +3,7 @@
@layout Layout
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; New Transaction</PageTitle>
@ -41,7 +41,8 @@
protected override async Task OnInitializedAsync()
{
stores = await DbContext.Stores
using var dbContext = DbContextFactory.CreateDbContext();
stores = await dbContext.Stores
.OrderBy(store => store.Retailer!.Name)
.ThenBy(store => store.Name)
.Select(store => new StoreModel(store.Id, string.Concat(store.Retailer!.Name, " ", store.Name)))

View File

@ -3,7 +3,7 @@
@layout Layout
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; New Transaction Promotion</PageTitle>
@ -28,7 +28,8 @@
protected override async Task OnParametersSetAsync()
{
store = await DbContext.Stores
using var dbContext = DbContextFactory.CreateDbContext();
store = await dbContext.Stores
.Where(store => store.Id == Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync();

View File

@ -2,7 +2,8 @@
@using Microsoft.EntityFrameworkCore
@layout Layout
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; New Transaction</PageTitle>
@ -22,7 +23,7 @@
</header>
<div class="card__content card__content--table">
<Table Items="Transaction.Promotions.AsQueryable()" CellClass="table__cell--compact">
<Table Items="Transaction.Promotions.AsQueryable()" HeaderClass="table__header--compact" CellClass="table__cell--compact">
<ChildContent>
<PropertyTableColumn Property="p => p.Name" Fill="true" />
<TemplateTableColumn Title="Items" Align="Align.End" Context="promotion">
@ -64,7 +65,8 @@
protected override async Task OnParametersSetAsync()
{
store = await DbContext.Stores
using var dbContext = DbContextFactory.CreateDbContext();
store = await dbContext.Stores
.Where(store => store.Id == Transaction.StoreId)
.Select(store => string.Concat(store.Retailer!.Name, " ", store.Name))
.SingleAsync();

View File

@ -1,7 +1,7 @@
@using Groceries.Data
@using Microsoft.EntityFrameworkCore
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
<form method="post" @attributes="AdditionalAttributes">
@* Ensure form action/method are used for implicit submission instead of barcode button *@
@ -36,34 +36,41 @@
<datalist id="itemNames">
@foreach (var item in items)
{
<option value="@item.Name" data-transaction-item-form-target="option" data-brand="@item.Brand" data-price="@item.Price" data-quantity="@item.Quantity" />
<option value="@item.Name" data-transaction-item-form-target="option" data-brand="@item.Brand" data-price="@item.Price" data-quantity="@(unit == null ? (int?)item.Quantity : item.Quantity)" />
}
</datalist>
</div>
</fieldset>
<div class="form-field">
<label class="form-field__label" for="transactionItemPrice">Price</label>
<label class="form-field__label" for="transactionItemPrice">
Price @if (unit != null) { <text>(per @unit)</text> }
</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="@price" type="number" min="0" step="0.01" required data-transaction-item-form-target="price" />
<input class="input__control" id="transactionItemPrice" name="price" value="@price" type="number" min="0" step="0.01" required />
</div>
</div>
<div class="form-field">
<label class="form-field__label" for="transactionItemQuantity">Quantity</label>
<label class="form-field__label" for="transactionItemQuantity">
Quantity @if (unit != null) { <text>(@unit)</text> }
</label>
<div class="form-field__control input">
<input class="input__control" id="transactionItemQuantity" name="quantity" value="@quantity" type="number" min="1" required data-transaction-item-form-target="quantity" />
@{ var step = unit == null ? "1" : "0.001"; }
<input class="input__control" id="transactionItemQuantity" name="quantity" value="@(unit == null ? (int?)quantity : quantity)" type="number" min="@step" step="@step" required />
</div>
</div>
<input type="hidden" name="unit" value="@unit" />
</div>
@ChildContent
</form>
@code {
[Parameter]
public TransactionItem? TransactionItem { get; set; }
[Parameter, EditorRequired]
public required TransactionItem TransactionItem { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
@ -71,7 +78,7 @@
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private record ItemModel(Guid Id, string Brand, string Name, decimal? Price, int? Quantity);
private record ItemModel(Guid Id, string Brand, string Name, decimal? Price, decimal? Quantity);
private ItemBarcode? barcode;
@ -79,33 +86,38 @@
private ItemModel? selectedItem;
private decimal? price;
private int quantity;
private decimal? quantity;
private string? unit;
protected override async Task OnParametersSetAsync()
{
barcode = TransactionItem?.Item?.Barcodes.FirstOrDefault();
barcode = TransactionItem.Item?.Barcodes.FirstOrDefault();
items = await DbContext.Items
using var dbContext = DbContextFactory.CreateDbContext();
items = await dbContext.Items
.OrderBy(item => item.Brand)
.ThenBy(item => item.Name)
.GroupJoin(
DbContext.ItemPurchases.Where(purchase => purchase.IsLastPurchase),
.LeftJoin(
dbContext.ItemPurchases.Where(purchase => purchase.IsLastPurchase),
item => item.Id,
lastPurchase => lastPurchase.ItemId,
(item, purchases) => new { item, purchases })
.SelectMany(
group => group.purchases.DefaultIfEmpty(),
(group, lastPurchase) => new ItemModel(
group.item.Id,
group.item.Brand,
group.item.Name,
(item, lastPurchase) => new ItemModel(
item.Id,
item.Brand,
item.Name,
lastPurchase != null ? lastPurchase.Price : null,
lastPurchase != null ? lastPurchase.Quantity : null))
.ToArrayAsync();
selectedItem = items.SingleOrDefault(item => item.Id == TransactionItem?.ItemId);
selectedItem = items.SingleOrDefault(item => item.Id == TransactionItem.ItemId);
price = TransactionItem?.Price >= 0 ? TransactionItem.Price : selectedItem?.Price;
quantity = TransactionItem?.Quantity >= 1 ? TransactionItem.Quantity : (selectedItem?.Quantity ?? 1);
price = TransactionItem.Price >= 0 ? TransactionItem.Price : selectedItem?.Price;
quantity = TransactionItem.Quantity >= 0 ? TransactionItem.Quantity : selectedItem?.Quantity;
unit = TransactionItem.Unit;
if (unit == null)
{
quantity ??= 1;
}
}
}

View File

@ -1,7 +1,7 @@
@using Groceries.Data
@using Microsoft.EntityFrameworkCore
@inject AppDbContext DbContext
@inject IDbContextFactory<AppDbContext> DbContextFactory
<form method="post" @attributes="AdditionalAttributes">
<div class="form-field">
@ -56,8 +56,9 @@
{
selectedItemIds = Promotion?.Items.Select(item => item.Id).ToArray() ?? [];
using var dbContext = DbContextFactory.CreateDbContext();
var itemIds = Transaction.Items.Select(item => item.ItemId);
itemNames = await DbContext.Items
itemNames = await dbContext.Items
.Where(item => itemIds.Contains(item.Id))
.ToDictionaryAsync(item => item.Id, item => string.Concat(item.Brand, " ", item.Name));
}

View File

@ -9,11 +9,11 @@ using System.Text.Json;
[Route("/transactions")]
public class TransactionsController : Controller
{
private readonly AppDbContext dbContext;
private readonly IDbContextFactory<AppDbContext> dbContextFactory;
public TransactionsController(AppDbContext dbContext)
public TransactionsController(IDbContextFactory<AppDbContext> dbContextFactory)
{
this.dbContext = dbContext;
this.dbContextFactory = dbContextFactory;
}
[HttpGet]
@ -60,28 +60,39 @@ public class TransactionsController : Controller
}
[HttpGet("new/items/new")]
public async Task<IResult> NewTransactionItem(long? barcodeData, string? barcodeFormat)
public async Task<IResult> NewTransactionItem(string? unit, long? barcodeData, string? barcodeFormat)
{
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
return Results.LocalRedirect("/transactions/new");
}
TransactionItem? transactionItem = null;
Item? item = null;
if (barcodeData != null && barcodeFormat != null)
{
var item = await dbContext.Items
using var dbContext = dbContextFactory.CreateDbContext();
item = await dbContext.Items
.Where(item => item.Barcodes.Any(barcode => barcode.BarcodeData == barcodeData))
.OrderByDescending(item => item.UpdatedAt)
.FirstOrDefaultAsync();
item ??= new Item(id: default);
item.Barcodes.Add(new ItemBarcode(item.Id, barcodeData.Value, barcodeFormat));
// TODO: Fix `MinValue` hack - view models?
transactionItem = new TransactionItem(item.Id, decimal.MinValue, int.MinValue) { Item = item };
var barcode = new ItemBarcode(item.Id, barcodeData.Value, barcodeFormat);
item.Barcodes.Add(barcode);
if (item.Id != default)
{
barcode.LastScannedAt = DateTime.UtcNow;
dbContext.Update(barcode);
await dbContext.SaveChangesAsync();
}
}
// TODO: Fix `MinValue` hack - view models?
var transactionItem = new TransactionItem(item?.Id ?? default, decimal.MinValue, decimal.MinValue, unit) { Item = item };
var parameters = new { Transaction = transaction, TransactionItem = transactionItem };
return Request.IsTurboFrameRequest("modal")
? new RazorComponentResult<NewTransactionItemModal>(parameters)
@ -89,13 +100,15 @@ public class TransactionsController : Controller
}
[HttpPost("new/items/new")]
public async Task<IResult> NewTransactionItem(string brand, string name, decimal price, int quantity, long? barcodeData, string? barcodeFormat)
public async Task<IResult> NewTransactionItem(string brand, string name, decimal price, decimal quantity, string? unit, long? barcodeData, string? barcodeFormat)
{
if (TempData.Peek("NewTransaction") is not string json || JsonSerializer.Deserialize<Transaction>(json) is not Transaction transaction)
{
return Results.LocalRedirect("/transactions/new");
}
using var dbContext = dbContextFactory.CreateDbContext();
var itemId = await dbContext.Items
.Where(item => EF.Functions.ILike(item.Brand, brand) && EF.Functions.ILike(item.Name, name))
.Select(item => item.Id)
@ -118,7 +131,7 @@ public class TransactionsController : Controller
// TODO: Handle item already in transaction - merge, replace, error?
var transactionItem = new TransactionItem(item.Id, price, quantity) { Item = item };
var transactionItem = new TransactionItem(item.Id, price, quantity, unit) { Item = item };
transaction.Items.Add(transactionItem);
TempData["NewTransaction"] = JsonSerializer.Serialize(transaction);
@ -162,6 +175,8 @@ public class TransactionsController : Controller
return Results.LocalRedirect("/transactions/new/items");
}
using var dbContext = dbContextFactory.CreateDbContext();
var itemId = await dbContext.Items
.Where(item => EF.Functions.ILike(item.Brand, brand) && EF.Functions.ILike(item.Name, name))
.Select(item => item.Id)
@ -226,11 +241,15 @@ public class TransactionsController : Controller
return Results.LocalRedirect("/transactions/new");
}
using var dbContext = dbContextFactory.CreateDbContext();
foreach (var item in transaction.Items)
{
item.Item = null;
}
// Work around EF trying to insert items by explicitly tracking them as unchanged
dbContext.Items.AttachRange(
transaction.Items
.Select(item => item.Item!)
.Concat(transaction.Promotions.SelectMany(promotion => promotion.Items)));
dbContext.Items.AttachRange(transaction.Promotions.SelectMany(promotion => promotion.Items));
dbContext.Transactions.Add(transaction);
await dbContext.SaveChangesAsync();

View File

@ -1,7 +1,10 @@
@using Groceries.Data
@using Microsoft.EntityFrameworkCore
@layout Layout
@inject AppDbContext DbContext
@implements IDisposable
@inject IDbContextFactory<AppDbContext> DbContextFactory
<PageTitle>Groceries &ndash; Transactions</PageTitle>
@ -35,14 +38,16 @@
public int TotalItems { get; init; }
}
private AppDbContext? dbContext;
private IQueryable<TransactionModel> transactions = null!;
private PaginationState pagination = new();
protected override void OnParametersSet()
{
transactions = DbContext.Transactions
dbContext ??= DbContextFactory.CreateDbContext();
transactions = dbContext.Transactions
.Join(
DbContext.TransactionTotals,
dbContext.TransactionTotals,
transaction => transaction.Id,
transactionTotal => transactionTotal.TransactionId,
(transaction, transactionTotal) => new TransactionModel
@ -51,8 +56,13 @@
CreatedAt = transaction.CreatedAt,
Store = string.Concat(transaction.Store!.Retailer!.Name, " ", transaction.Store.Name),
TotalAmount = transactionTotal.Total,
TotalItems = transaction.Items.Sum(item => item.Quantity),
TotalItems = transaction.Items.Sum(item => item.Unit == null ? (int)item.Quantity : 1),
})
.OrderByDescending(transaction => transaction.CreatedAt);
}
public void Dispose()
{
dbContext?.Dispose();
}
}

View File

@ -3,7 +3,11 @@
"defaultProvider": "unpkg",
"libraries": [
{
"library": "@hotwired/turbo@7.3.0",
"library": "@fontsource-variable/inter@5.2.5",
"destination": "wwwroot/lib/inter/"
},
{
"library": "@hotwired/turbo@8.0.13",
"destination": "wwwroot/lib/hotwired/turbo/"
},
{

View File

@ -1,16 +1,8 @@
@import url("https://rsms.me/inter/inter.css");
:root {
font-family: "Inter", sans-serif;
font-family: "Inter Variable", sans-serif;
color-scheme: light;
}
@supports (font-variation-settings: normal) {
:root {
font-family: "Inter var", sans-serif;
}
}
* {
margin: 0;
padding: 0;
@ -77,6 +69,14 @@ h1, h2, h3, h4, h5, h6 {
flex: 5;
}
.line-clamp-4 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
white-space: initial;
}
.row {
display: flex;
align-items: baseline;
@ -257,6 +257,7 @@ html:has(.modal[open]) {
/* HACK: should probably be a .button--icon */
.modal__close-button {
justify-content: center;
padding: 0 !important;
margin-block: -1rem;
width: 2rem;
@ -319,6 +320,12 @@ html:has(.modal[open]) {
border-color: transparent;
}
@media (max-width: 40rem) {
.card__header, .card__footer {
padding-inline: 1rem;
}
}
/* Table */
.table {
@ -381,15 +388,15 @@ html:has(.modal[open]) {
white-space: nowrap;
}
.table__cell--align-center {
.table__header--align-center, .table__cell--align-center {
text-align: center;
}
.table__cell--align-end {
.table__header--align-end, .table__cell--align-end {
text-align: end;
}
.table__cell--compact {
.table__header--compact, .table__cell--compact {
line-height: 1rem;
padding-block: 0.75rem;
}
@ -410,6 +417,29 @@ html:has(.modal[open]) {
padding: 0.75rem 1.5rem;
}
.table__paginator > nav {
display: flex;
gap: 1rem;
}
@media (max-width: 40rem) {
.table__header, .table__cell {
padding-inline: 1rem;
}
.table__header--compact, .table__cell--compact {
font-size: 0.75rem;
}
.table__header--compact:not(:first-child), .table__cell--compact:not(:first-child) {
padding-inline-start: 0.5rem;
}
.table__header--compact:not(:last-child), .table__cell--compact:not(:last-child) {
padding-inline-end: 0.5rem;
}
}
/*@media (prefers-color-scheme: dark) {
.table__header {
background-color: rgb(55, 65, 81);
@ -455,7 +485,8 @@ html:has(.modal[open]) {
/* Button */
.button {
display: inline-block;
display: inline-flex;
align-items: center;
text-decoration: none;
appearance: none;
background-color: rgb(255, 255, 255);
@ -465,7 +496,7 @@ html:has(.modal[open]) {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
padding: 0.5rem 1rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
}
@ -491,9 +522,64 @@ html:has(.modal[open]) {
opacity: 50%;
}
/* Button group */
.button-group {
display: flex;
gap: 1rem;
}
.button-group > .button:not(:nth-child(1 of .button)) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-inline-start: 1px solid;
}
.button-group > .button:not(:nth-last-child(1 of .button)) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
/* Dropdown */
.dropdown__toggle {
anchor-name: --dropdown-toggle;
}
.dropdown__toggle::after {
content: "";
display: inline-block;
border-block-start: 0.3rem solid;
border-block-end: 0;
border-inline: 0.3rem solid transparent;
}
.dropdown__toggle::after:not(:empty) {
margin-inline-start: 0.375rem;
}
.dropdown:has(> :popover-open) > .dropdown__toggle[popovertarget] {
outline: none;
filter: brightness(0.85);
}
.dropdown__menu {
position-anchor: --dropdown-toggle;
inset: calc(anchor(end) + 0.125rem) anchor(end) auto auto;
padding-block: 0.5rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.375rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.dropdown__item {
border-block-start: 1px solid rgb(229, 231, 235);
border-block-end: none;
border-inline: none;
border-radius: 0;
}
.dropdown__item:first-child {
border-block-start: none;
}
/* Form field */

View File

@ -13,7 +13,7 @@ export default class ModalController extends Controller {
if (!this.element.open) {
return;
}
if (event.type === "turbo:submit-end" && (event.detail.formSubmission.method === 0 || !event.detail.success)) {
if (event.type === "turbo:submit-end" && (event.detail.formSubmission.method === "GET" || !event.detail.success)) {
// Don't close modal if form method was GET or submission failed
return;
}

View File

@ -1,7 +1,7 @@
import { Controller } from "/lib/hotwired/stimulus/dist/stimulus.js";
export default class TransactionItemFormController extends Controller {
static targets = ["barcodeButton", "barcodeData", "barcodeFormat", "barcodeFormField", "brand", "option", "price", "quantity"];
static targets = ["barcodeButton", "barcodeData", "barcodeFormat", "barcodeFormField", "brand", "option"];
#scanning = false;
#scanIntervalId;
@ -35,10 +35,11 @@ export default class TransactionItemFormController extends Controller {
}
setPriceAndQuantity(event) {
const { brand, name } = event.target.form.elements;
const { brand, name, price, quantity, unit } = event.target.form.elements;
if (!brand.value || !name.value) {
this.priceTarget.value = "";
this.quantityTarget.value = "1";
price.value = "";
quantity.value = !unit.value ? "1" : "";
return;
}
@ -47,11 +48,11 @@ export default class TransactionItemFormController extends Controller {
option.value === name.value);
if (option != null) {
if (!this.priceTarget.value) {
this.priceTarget.value = option.getAttribute("data-price");
if (!price.value) {
price.value = option.getAttribute("data-price");
}
if (!this.quantityTarget.value || this.quantityTarget.value === "1") {
this.quantityTarget.value = option.getAttribute("data-quantity") || "1";
if (!quantity.value || (!unit.value && quantity.value === "1")) {
quantity.value = option.getAttribute("data-quantity") || (!unit.value ? "1" : "");
}
}
}

View File

@ -19,19 +19,3 @@ document.addEventListener("turbo:render", () => {
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();
}
}
});

View File

@ -2,12 +2,12 @@
An application for (manually) tracking your grocery shopping habits.
## Installation
A pre-built image is available at [ghcr.io/jamsch0/groceries](https://ghcr.io/jamsch0/groceries).
A pre-built image is available at [git.jamsch0.dev/jamsch0/groceries](https://git.jamsch0.dev/jamsch0/-/packages/container/groceries).
The default configuration can be found in `config.ini` once the volume has been created.
Example usage:
```bash
$ docker run -d -p 8080:80 -e LANG=en_GB TZ=Europe/London -v ./groceries:/config ghcr.io/jamsch0/groceries
$ docker run -d -p 8080:80 -e LANG=en_GB TZ=Europe/London -v ./groceries:/config git.jamsch0.dev/jamsch0/groceries
```
## Configuration