Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<base href="/" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700&family=Inter:wght@400;500;600&display=swap" />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["theme.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
Expand Down
21 changes: 12 additions & 9 deletions Features/PhysicalAssets/Inventory.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@

<PageTitle>Inventory</PageTitle>

<div class="standard-page">
<header class="page-header">
<div>
<h1>Physical Asset Inventory</h1>
<p class="page-subtitle">Every item you own, whether scanned or manually added.</p>
</div>
<a class="primary-button" href="#add-item">+ Add item</a>
</header>
<div class="ledger-page">
<header class="ledger-header">
<div>
<h1>Inventory</h1>
<p class="ledger-subtitle">Every item you own, whether scanned or manually added.</p>
</div>
<a class="ledger-cta" href="#add-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
Add item
</a>
</header>

<InventoryList />
<InventoryList />
</div>
104 changes: 104 additions & 0 deletions Features/PhysicalAssets/Inventory.razor.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/* "Pocket Ledger" mobile concept (see design/mobile-mockups/c-pocket-ledger.html)
built for real here, recolored to the site's own palette rather than a
distinct violet identity. Tokens are declared on this wrapper rather than
:root - scoped .razor.css files must never redeclare :root, see theme.css
- and inherit down into InventoryList.razor's markup through the normal
CSS cascade regardless of each component's own scope attribute. The
--pl-* names are kept (rather than renamed to the tokens they now equal)
so the rest of this file and InventoryList.razor.css did not need every
color property rewritten. Sora/Inter are loaded from App.razor's <head> -
scoped CSS files cannot use @import. */
.ledger-page {
--pl-bg: transparent;
--pl-card: var(--glass-bg);
--pl-violet: var(--brand);
--pl-violet-deep: var(--brand-strong);
--pl-violet-tint: var(--brand-surface);
--pl-ink: var(--text);
--pl-muted: var(--text-muted);
--pl-shadow: var(--shadow-2);

max-width: 640px;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
background: var(--pl-bg);
color: var(--pl-ink);
font-family: "Inter", sans-serif;
}

.ledger-header {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1rem;
margin-bottom: 1.25rem;
}

.ledger-header h1 {
margin: 0 0 0.35rem;
color: var(--pl-ink);
font-family: "Sora", sans-serif;
font-size: clamp(1.6rem, 5vw, 2.1rem);
font-weight: 700;
}

.ledger-subtitle {
max-width: 32ch;
margin: 0;
color: var(--pl-muted);
font-size: 0.85rem;
}

.ledger-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.6rem 1rem;
border-radius: 999px;
background: var(--pl-violet);
color: #fff;
font-family: "Sora", sans-serif;
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
box-shadow: var(--shadow-1);
}

.ledger-cta svg {
width: 15px;
height: 15px;
}

.ledger-cta:hover {
background: var(--pl-violet-deep);
color: #fff;
}

/* ===================================================================
* Desktop
* ===================================================================
* Color already matches at every width - these are the shape changes
* that make a page-header read the same as every other page's once
* there is room for a row layout instead of a stack.
*/
@media (min-width: 48rem) {
.ledger-page {
max-width: var(--content-max);
padding: 0.25rem 0 1rem;
}

.ledger-header {
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 1.75rem;
}

.ledger-header h1 {
font-family: var(--font-display);
font-size: clamp(2.25rem, 4vw, 3.25rem);
font-weight: 400;
}
}
139 changes: 96 additions & 43 deletions Features/PhysicalAssets/InventoryList.razor
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,32 @@

@if (_assets is null)
{
<p><em>Loading...</em></p>
<p class="ledger-loading"><em>Loading...</em></p>
}
else
{
<div class="asset-total">
<span class="asset-total-label">Total value</span>
<strong class="asset-total-amount">$@_assets.Sum(a => a.CurrentValuation ?? 0).ToString("N2")</strong>
@if (_assets.Any(a => a.CurrentValuation is null))
{
<span class="asset-total-note text-muted">(some items have no valuation yet)</span>
}
<div class="ledger-total-card">
<p class="ledger-total-label">Total value</p>
<div class="ledger-total-figure">
$@_assets.Sum(a => a.CurrentValuation ?? 0).ToString("N2")<small>USD</small>
</div>
<div class="ledger-total-foot">
<span>@_assets.Count @(_assets.Count == 1 ? "item" : "items") scanned</span>
@if (_assets.Any(a => a.CurrentValuation is null))
{
<span class="ledger-total-pill">some unvalued</span>
}
</div>
</div>

@if (_assets.Count == 0)
{
<p><em>No items yet. Add one below, or scan a possession.</em></p>
<div class="ledger-empty">
<span class="ledger-icon-badge ledger-icon-badge-lg">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 8l-9-5-9 5 9 5 9-5z"/><path d="M3 8v8l9 5 9-5V8"/><path d="M12 13v8"/></svg>
</span>
<p><em>No items yet. Add one below, or scan a possession.</em></p>
</div>
}
else
{
Expand Down Expand Up @@ -57,24 +67,19 @@ else
class="asset-row-summary"
aria-expanded="@(_expandedId == asset.Id)"
@onclick="() => ToggleExpandAsync(asset.Id)">
<span class="asset-row-name">@asset.Name</span>
<span class="asset-row-meta">
@asset.Category
@if (!string.IsNullOrWhiteSpace(asset.Category))
{
<text> &middot; </text>
}
@(asset.IsManuallyAdded ? "Manual" : "Scanned")
<span class="ledger-icon-badge">@CategoryIconMarkup(asset.Category)</span>
<span class="asset-row-main">
<span class="asset-row-name">@asset.Name</span>
<span class="asset-row-meta">
@(string.IsNullOrWhiteSpace(asset.Category)
? (asset.IsManuallyAdded ? "Manual" : "Scanned")
: $"{asset.Category} \u00b7 {(asset.IsManuallyAdded ? "Manual" : "Scanned")}")
</span>
</span>
<span class="asset-row-value">@(asset.CurrentValuation is { } v ? $"${v:N2}" : "—")</span>
<span class="asset-row-caret" aria-hidden="true"></span>
</button>

<div class="asset-row-quick-actions">
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BeginEdit(asset)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(asset.Id)">Delete</button>
</div>

@if (_expandedId == asset.Id)
{
<div class="asset-row-detail">
Expand All @@ -101,22 +106,24 @@ else
<dd>@(_detail.IdentifiedProductModel ?? "—")</dd>
</dl>

<div class="asset-detail-revalue">
<div class="ledger-detail-actions">
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BeginEdit(asset)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(asset.Id)">Delete</button>
<button
class="btn btn-outline-primary btn-sm"
class="btn btn-sm btn-outline-primary"
disabled="@_isRevaluing"
@onclick="RevalueAsync">
@(_isRevaluing ? "Revaluing..." : "Revalue")
</button>
@if (_revalueError is not null)
{
<div class="mt-2">
<ErrorAlert Message="@_revalueError" RawResponse="@_revalueRawResponse" />
</div>
}
</div>
@if (_revalueError is not null)
{
<div class="mt-2">
<ErrorAlert Message="@_revalueError" RawResponse="@_revalueRawResponse" />
</div>
}

<h3>Valuation History</h3>
<h3 class="ledger-subheading">Valuation History</h3>
@if (_detail.ValuationHistory.Count == 0)
{
<p><em>No valuations yet.</em></p>
Expand Down Expand Up @@ -172,18 +179,16 @@ else

@if (ShowAddForm)
{
<div id="add-item">
<h2>Add Item</h2>
<p class="text-muted">Its value is estimated automatically, the same way a scanned possession's is.</p>
<div class="row g-2">
<div class="col"><input class="form-control" placeholder="Name" @bind="_newName" /></div>
<div class="col"><input class="form-control" placeholder="Category" @bind="_newCategory" /></div>
<div class="col"><input class="form-control" placeholder="Product/Model" @bind="_newModel" /></div>
<div class="col-auto">
<button class="btn btn-primary" @onclick="AddAsync" disabled="@(string.IsNullOrWhiteSpace(_newName) || _isAdding)">
@(_isAdding ? "Estimating value..." : "Add")
</button>
</div>
<div id="add-item" class="ledger-add-card">
<h2 class="ledger-subheading">Add item</h2>
<p class="ledger-hint">Its value is estimated automatically, the same way a scanned possession's is.</p>
<div class="ledger-add-grid">
<input class="form-control" placeholder="Name" @bind="_newName" />
<input class="form-control" placeholder="Category" @bind="_newCategory" />
<input class="form-control" placeholder="Product/Model" @bind="_newModel" />
<button class="btn btn-primary" @onclick="AddAsync" disabled="@(string.IsNullOrWhiteSpace(_newName) || _isAdding)">
@(_isAdding ? "Estimating value..." : "Add")
</button>
</div>
</div>
}
Expand Down Expand Up @@ -415,4 +420,52 @@ else
_isRevaluing = false;
}
}

// Purely cosmetic: picks a recognizable glyph for the row's icon badge
// from the free-text category. Falls back to a generic box for anything
// that doesn't match a known bucket - never blocks on an unrecognized
// category.
private static string CategoryIconKey(string? category)
{
if (string.IsNullOrWhiteSpace(category))
{
return "box";
}

var c = category.ToLowerInvariant();
if (c.Contains("electronic") || c.Contains("tech") || c.Contains("computer") || c.Contains("phone") || c.Contains("device"))
{
return "device";
}
if (c.Contains("kitchen") || c.Contains("housewares") || c.Contains("home"))
{
return "mug";
}
if (c.Contains("pantry") || c.Contains("food") || c.Contains("grocery") || c.Contains("snack"))
{
return "bag";
}
if (c.Contains("furniture"))
{
return "chair";
}
if (c.Contains("cloth") || c.Contains("apparel") || c.Contains("wear"))
{
return "shirt";
}

return "box";
}

// The SVG markup is a fixed constant per icon key - never built from the
// category text itself - so this stays safe to render unescaped.
private static MarkupString CategoryIconMarkup(string? category) => new(CategoryIconKey(category) switch
{
"device" => """<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="7" y="2" width="10" height="20" rx="2"/><line x1="11" y1="18" x2="13" y2="18"/></svg>""",
"mug" => """<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4h11v9a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V4z"/><path d="M15 7h2a3 3 0 0 1 0 6h-2"/></svg>""",
"bag" => """<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 3l1 4-2 2 1 12h12l1-12-2-2 1-4z"/></svg>""",
"chair" => """<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 4v9a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4"/><path d="M7 15v5M17 15v5"/><path d="M6 9h12"/></svg>""",
"shirt" => """<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 3l4 2 4-2 4 4-3 3v11H7V10L4 7z"/></svg>""",
_ => """<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 8l-9-5-9 5 9 5 9-5z"/><path d="M3 8v8l9 5 9-5V8"/><path d="M12 3v10"/></svg>"""
});
}
Loading
Loading