feat: add colored category icons, quantity badges, and remove collapsing in shopping list
All checks were successful
CI / update (push) Successful in 54s
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:
@@ -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": {
|
||||||
|
|||||||
@@ -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">
|
||||||
<h2>{group.category}</h2>
|
<div class="category-title">
|
||||||
|
<div class="category-icon">
|
||||||
|
<CategoryIcon size={14} />
|
||||||
|
</div>
|
||||||
|
<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;
|
||||||
|
|||||||
Reference in New Issue
Block a user