feat: add colored category icons, quantity badges, and remove collapsing in shopping list
All checks were successful
CI / update (push) Successful in 54s

Add Lucide icons and Nord colors per category, parse quantities from item names
(e.g. "10L Milch" → badge "10L" + name "Milch"), and remove category collapse toggling.
This commit is contained in:
2026-04-08 00:12:36 +02:00
parent 738875e89f
commit fc31c208ef
2 changed files with 103 additions and 24 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.7.0", "version": "1.8.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -2,7 +2,7 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { getShoppingSync } from '$lib/js/shoppingSync.svelte'; import { getShoppingSync } from '$lib/js/shoppingSync.svelte';
import { SHOPPING_CATEGORIES } from '$lib/data/shoppingCategoryItems'; import { SHOPPING_CATEGORIES } from '$lib/data/shoppingCategoryItems';
import { Plus, ListX } from '@lucide/svelte'; import { Plus, ListX, Apple, Beef, Milk, Croissant, Wheat, FlameKindling, GlassWater, Candy, Snowflake, SprayCan, Sparkles, Package } from '@lucide/svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
@@ -11,13 +11,59 @@
let user = $derived(data.session?.user?.nickname || ''); let user = $derived(data.session?.user?.nickname || '');
const sync = getShoppingSync(); const sync = getShoppingSync();
/** @type {Record<string, { icon: typeof Plus, color: string }>} */
const categoryMeta = {
'Obst & Gemüse': { icon: Apple, color: 'var(--nord14)' },
'Fleisch & Fisch': { icon: Beef, color: 'var(--nord11)' },
'Milchprodukte': { icon: Milk, color: 'var(--nord9)' },
'Brot & Backwaren': { icon: Croissant, color: 'var(--nord12)' },
'Pasta, Reis & Getreide': { icon: Wheat, color: 'var(--nord13)' },
'Gewürze & Saucen': { icon: FlameKindling, color: 'var(--nord11)' },
'Getränke': { icon: GlassWater, color: 'var(--nord10)' },
'Süßes & Snacks': { icon: Candy, color: 'var(--nord15)' },
'Tiefkühl': { icon: Snowflake, color: 'var(--nord9)' },
'Haushalt': { icon: SprayCan, color: 'var(--nord8)' },
'Hygiene & Körperpflege': { icon: Sparkles, color: 'var(--nord15)' },
'Sonstiges': { icon: Package, color: 'var(--nord4)' },
};
let newItemName = $state(''); let newItemName = $state('');
/** @type {HTMLInputElement | null} */ /** @type {HTMLInputElement | null} */
let inputEl = $state(null); let inputEl = $state(null);
let categorizing = new SvelteSet(); let categorizing = new SvelteSet();
/** @type {Record<string, boolean>} */
let collapsed = $state({}); /**
* Parse quantity + unit from item name.
* "10L Milch" → { qty: "10L", name: "Milch" }
* "3x Milch" → { qty: "3x", name: "Milch" }
* "3 x Milch" → { qty: "3x", name: "Milch" }
* "Milch, 3x" → { qty: "3x", name: "Milch" }
* "Milch 3x" → { qty: "3x", name: "Milch" }
* "500g Hackfleisch" → { qty: "500g", name: "Hackfleisch" }
* "Milch" → { qty: null, name: "Milch" }
* @param {string} raw
* @returns {{ qty: string | null, name: string }}
*/
function parseQuantity(raw) {
// Trailing: "Milch, 3x" or "Milch 3x" or "Milch, 500g"
const trailingMatch = raw.match(/^(.+?)[,\s]+(\d+\s*[xX×]|\d+(?:\.\d+)?\s*(?:L|l|kg|g|ml|mL|st|St|Stk|stk|Pkg|pkg))\s*$/);
if (trailingMatch) {
return { qty: trailingMatch[2].replace(/\s+/g, ''), name: trailingMatch[1].trim() };
}
// Leading: "3x Milch" or "3 x Milch" or "10L Milch" or "500g Hackfleisch"
const leadingMatch = raw.match(/^(\d+(?:\.\d+)?\s*[xX×]|\d+(?:\.\d+)?\s*(?:L|l|kg|g|ml|mL|st|St|Stk|stk|Pkg|pkg)?)\s+(.+)$/);
if (leadingMatch) {
const qtyRaw = leadingMatch[1].replace(/\s+/g, '');
// Only treat bare numbers as quantity if followed by text (avoid stripping "7up")
if (/[xX×LlgkmsSPp]/.test(qtyRaw) || /^\d+$/.test(qtyRaw)) {
return { qty: qtyRaw, name: leadingMatch[2].trim() };
}
}
return { qty: null, name: raw };
}
/** Get icon URL for an item */ /** Get icon URL for an item */
function iconUrl(item) { function iconUrl(item) {
@@ -76,11 +122,12 @@
categorizing.add(itemId); categorizing.add(itemId);
try { try {
console.log(`[shopping] Categorizing "${name}" (item ${itemId})...`); const cleanName = parseQuantity(name).name;
console.log(`[shopping] Categorizing "${cleanName}" (item ${itemId})...`);
const res = await fetch('/api/cospend/list/categorize', { const res = await fetch('/api/cospend/list/categorize', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }) body: JSON.stringify({ name: cleanName })
}); });
console.log(`[shopping] Categorize response: ${res.status}`); console.log(`[shopping] Categorize response: ${res.status}`);
if (res.ok) { if (res.ok) {
@@ -102,10 +149,6 @@
if (e.key === 'Enter') { e.preventDefault(); addItem(); } if (e.key === 'Enter') { e.preventDefault(); addItem(); }
} }
/** @param {string} cat */
function toggleCollapse(cat) {
collapsed = { ...collapsed, [cat]: !collapsed[cat] };
}
</script> </script>
<div class="shopping-page"> <div class="shopping-page">
@@ -135,17 +178,22 @@
{:else} {:else}
<div class="item-list"> <div class="item-list">
{#each groupedItems as group (group.category)} {#each groupedItems as group (group.category)}
<section class="category-section" transition:slide={{ duration: 200 }}> {@const meta = categoryMeta[group.category] || categoryMeta['Sonstiges']}
<!-- svelte-ignore a11y_no_static_element_interactions --> {@const CategoryIcon = meta.icon}
<!-- svelte-ignore a11y_click_events_have_key_events --> <section class="category-section" style="--cat-color: {meta.color}" transition:slide={{ duration: 200 }}>
<div class="category-header" onclick={() => toggleCollapse(group.category)}> <div class="category-header">
<div class="category-title">
<div class="category-icon">
<CategoryIcon size={14} />
</div>
<h2>{group.category}</h2> <h2>{group.category}</h2>
</div>
<span class="category-count">{group.items.filter(i => !i.checked).length}</span> <span class="category-count">{group.items.filter(i => !i.checked).length}</span>
</div> </div>
{#if !collapsed[group.category]} <div class="card-grid">
<div class="card-grid" transition:slide={{ duration: 150 }}>
{#each group.items as item (item.id)} {#each group.items as item (item.id)}
{@const parsed = parseQuantity(item.name)}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
@@ -154,18 +202,20 @@
animate:flip={{ duration: 200 }} animate:flip={{ duration: 200 }}
onclick={() => sync.toggleItem(item.id, user)} onclick={() => sync.toggleItem(item.id, user)}
> >
{#if parsed.qty}
<span class="qty-badge">{parsed.qty}</span>
{/if}
<div class="card-icon"> <div class="card-icon">
{#if iconUrl(item)} {#if iconUrl(item)}
<img src={iconUrl(item)} alt="" /> <img src={iconUrl(item)} alt="" />
{:else} {:else}
<span class="card-letter">{item.name.charAt(0)}</span> <span class="card-letter">{parsed.name.charAt(0)}</span>
{/if} {/if}
</div> </div>
<span class="card-name">{item.name}</span> <span class="card-name">{parsed.name}</span>
</div> </div>
{/each} {/each}
</div> </div>
{/if}
</section> </section>
{/each} {/each}
</div> </div>
@@ -266,22 +316,36 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.4rem 0.2rem; padding: 0.4rem 0.2rem;
cursor: pointer;
user-select: none; user-select: none;
} }
.category-title {
display: flex;
align-items: center;
gap: 0.4rem;
}
.category-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 6px;
color: var(--cat-color);
background: color-mix(in srgb, var(--cat-color) 12%, transparent);
}
.category-header h2 { .category-header h2 {
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.03em;
color: var(--color-text-secondary); color: var(--cat-color);
} }
.category-count { .category-count {
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-secondary); color: var(--cat-color);
background: var(--color-bg-tertiary); background: color-mix(in srgb, var(--cat-color) 10%, var(--color-bg-tertiary));
padding: 0.1rem 0.45rem; padding: 0.1rem 0.45rem;
border-radius: 100px; border-radius: 100px;
} }
@@ -295,6 +359,7 @@
} }
.item-card { .item-card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -362,6 +427,20 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.qty-badge {
position: absolute;
top: 0.2rem;
left: 0.2rem;
background: var(--cat-color);
color: var(--nord0);
font-size: 0.72rem;
font-weight: 700;
padding: 0.15rem 0.35rem;
border-radius: 0.5rem;
line-height: 1.2;
white-space: nowrap;
}
/* Clear checked */ /* Clear checked */
.btn-clear-checked { .btn-clear-checked {
display: flex; display: flex;