feat: stronger checked-off effect, long-press edit modal, SyncIndicator icon
All checks were successful
CI / update (push) Successful in 3m47s

- Diagonal strikethrough line + lower opacity on checked cards
- Long press opens edit modal to manually assign category and icon (saved to DB)
- Replace floating status toasts with inline SyncIndicator (Cloud/CloudOff/RefreshCw)
- Move category count badge next to title instead of right-aligned
This commit is contained in:
2026-04-08 08:21:34 +02:00
parent 4fe828e228
commit 52d278bcd8
2 changed files with 313 additions and 25 deletions

View File

@@ -0,0 +1,25 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { ShoppingItemCategory } from '$models/ShoppingItemCategory';
import { dbConnect } from '$utils/db';
// POST /api/cospend/list/categorize/override — manually set category + icon for an item name
export const POST: RequestHandler = async ({ request, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
const { name, category, icon } = await request.json();
if (!name || typeof name !== 'string') throw error(400, 'name is required');
if (!category || typeof category !== 'string') throw error(400, 'category is required');
const normalizedName = name.toLowerCase().trim();
await dbConnect();
await ShoppingItemCategory.findOneAndUpdate(
{ normalizedName },
{ normalizedName, originalName: name, category, icon: icon || null },
{ upsert: true }
);
return json({ ok: true });
};

View File

@@ -2,10 +2,12 @@
import { onMount, onDestroy } from 'svelte';
import { getShoppingSync } from '$lib/js/shoppingSync.svelte';
import { SHOPPING_CATEGORIES } from '$lib/data/shoppingCategoryItems';
import { Plus, ListX, Apple, Beef, Milk, Croissant, Wheat, FlameKindling, GlassWater, Candy, Snowflake, SprayCan, Sparkles, Package } from '@lucide/svelte';
import { Plus, ListX, Apple, Beef, Milk, Croissant, Wheat, FlameKindling, GlassWater, Candy, Snowflake, SprayCan, Sparkles, Package, Search } from '@lucide/svelte';
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
import { flip } from 'svelte/animate';
import { slide } from 'svelte/transition';
import { SvelteSet } from 'svelte/reactivity';
import catalogData from '$lib/data/shoppingCatalog.json';
let { data } = $props();
let user = $derived(data.session?.user?.nickname || '');
@@ -149,11 +151,70 @@
if (e.key === 'Enter') { e.preventDefault(); addItem(); }
}
// --- Long press edit ---
/** @type {number | null} */
let longPressTimer = $state(null);
/** @type {import('$lib/js/shoppingSync.svelte').ShoppingItem | null} */
let editingItem = $state(null);
let editCategory = $state('');
let editIcon = $state('');
let iconSearch = $state('');
let editSaving = $state(false);
const allIcons = Object.entries(/** @type {Record<string, string>} */ (catalogData));
let filteredIcons = $derived(
iconSearch.trim()
? allIcons.filter(([name]) => name.includes(iconSearch.toLowerCase()))
: allIcons
);
/** @param {import('$lib/js/shoppingSync.svelte').ShoppingItem} item */
function startLongPress(item) {
longPressTimer = window.setTimeout(() => {
editingItem = item;
editCategory = item.category;
editIcon = item.icon || '';
iconSearch = '';
}, 500);
}
function cancelLongPress() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function closeEdit() {
editingItem = null;
editSaving = false;
}
async function saveEdit() {
if (!editingItem) return;
editSaving = true;
const cleanName = parseQuantity(editingItem.name).name;
try {
await fetch('/api/cospend/list/categorize/override', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: cleanName, category: editCategory, icon: editIcon || null })
});
sync.updateItemCategory(editingItem.id, editCategory, editIcon || null);
closeEdit();
} catch (err) {
console.error('[shopping] Save override error:', err);
editSaving = false;
}
}
</script>
<div class="shopping-page">
<header class="page-header">
<h1>Einkaufsliste</h1>
<h1>Einkaufsliste <SyncIndicator status={sync.status} /></h1>
{#if totalCount > 0}
<p class="subtitle">{checkedCount} / {totalCount} erledigt</p>
{/if}
@@ -187,8 +248,8 @@
<CategoryIcon size={14} />
</div>
<h2>{group.category}</h2>
<span class="category-count">{group.items.filter(i => !i.checked).length}</span>
</div>
<span class="category-count">{group.items.filter(i => !i.checked).length}</span>
</div>
<div class="card-grid">
@@ -201,6 +262,10 @@
class:checked={item.checked}
animate:flip={{ duration: 200 }}
onclick={() => sync.toggleItem(item.id, user)}
onpointerdown={() => startLongPress(item)}
onpointerup={cancelLongPress}
onpointerleave={cancelLongPress}
oncontextmenu={(e) => e.preventDefault()}
>
{#if parsed.qty}
<span class="qty-badge">{parsed.qty}</span>
@@ -228,13 +293,62 @@
{/if}
{/if}
{#if sync.status === 'offline'}
<div class="status-badge offline">Offline</div>
{:else if sync.status === 'syncing'}
<div class="status-badge syncing">Synchronisiere...</div>
{/if}
</div>
{#if editingItem}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="edit-backdrop" onclick={closeEdit}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="edit-modal" onclick={(e) => e.stopPropagation()}>
<h3>{parseQuantity(editingItem.name).name}</h3>
<label class="edit-label">Kategorie</label>
<div class="category-picker">
{#each SHOPPING_CATEGORIES as cat}
{@const meta = categoryMeta[cat] || categoryMeta['Sonstiges']}
{@const CatIcon = meta.icon}
<button
class="cat-option"
class:selected={editCategory === cat}
style="--cat-color: {meta.color}"
onclick={() => { editCategory = cat; }}
>
<CatIcon size={14} />
<span>{cat}</span>
</button>
{/each}
</div>
<label class="edit-label">Icon</label>
<div class="icon-search">
<Search size={14} />
<input bind:value={iconSearch} type="text" placeholder="Icon suchen..." />
</div>
<div class="icon-picker">
{#each filteredIcons as [name, file]}
<button
class="icon-option"
class:selected={editIcon === file}
onclick={() => { editIcon = file; }}
title={name}
>
<img src="https://bocken.org/static/shopping-icons/{file}.png" alt={name} />
</button>
{/each}
</div>
<div class="edit-actions">
<button class="btn-cancel" onclick={closeEdit}>Abbrechen</button>
<button class="btn-save" onclick={saveEdit} disabled={editSaving}>
{editSaving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
{/if}
<style>
.shopping-page {
max-width: 700px;
@@ -314,7 +428,6 @@
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.2rem;
user-select: none;
}
@@ -381,9 +494,24 @@
transform: scale(0.95);
}
.item-card.checked {
opacity: 0.45;
opacity: 0.35;
background: color-mix(in srgb, var(--nord14) 8%, var(--color-surface));
}
.item-card.checked::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to top right,
transparent calc(50% - 1px),
var(--color-text-secondary) calc(50% - 1px),
var(--color-text-secondary) calc(50% + 1px),
transparent calc(50% + 1px)
);
border-radius: 12px;
pointer-events: none;
opacity: 0.5;
}
.card-icon {
width: 44px;
@@ -463,21 +591,6 @@
background: color-mix(in srgb, var(--nord11) 6%, transparent);
}
/* Status */
.status-badge {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
padding: 0.35rem 0.8rem;
border-radius: 100px;
font-size: 0.72rem;
font-weight: 600;
z-index: 50;
}
.status-badge.offline { background: var(--nord11); color: white; }
.status-badge.syncing { background: var(--nord13); color: var(--nord0); }
@media (max-width: 500px) {
.shopping-page { padding: 1rem 0.75rem; }
h1 { font-size: 1.3rem; }
@@ -488,4 +601,154 @@
.card-icon { width: 36px; height: 36px; }
.card-name { font-size: 0.68rem; }
}
/* Edit modal */
.edit-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.edit-modal {
background: var(--color-bg-secondary);
border-radius: 16px;
padding: 1.5rem;
width: 100%;
max-width: 480px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.edit-modal h3 {
margin: 0 0 1rem;
font-size: 1.2rem;
color: var(--color-text-primary);
}
.edit-label {
display: block;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
}
.category-picker {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 1.25rem;
}
.cat-option {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
font-size: 0.72rem;
cursor: pointer;
transition: all 150ms;
}
.cat-option:hover {
border-color: var(--cat-color);
}
.cat-option.selected {
background: var(--cat-color);
color: var(--nord0);
border-color: var(--cat-color);
}
.icon-search {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
}
.icon-search input {
border: none;
background: none;
color: var(--color-text-primary);
font-size: 0.85rem;
flex: 1;
outline: none;
}
.icon-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: 0.35rem;
max-height: 200px;
overflow-y: auto;
margin-bottom: 1.25rem;
padding: 0.25rem;
}
.icon-option {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 2px solid transparent;
background: var(--color-bg-tertiary);
cursor: pointer;
padding: 0.25rem;
transition: all 150ms;
}
.icon-option:hover {
border-color: var(--color-border);
}
.icon-option.selected {
border-color: var(--nord10);
background: color-mix(in srgb, var(--nord10) 15%, var(--color-bg-tertiary));
}
.icon-option img {
width: 100%;
height: 100%;
object-fit: contain;
}
.edit-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.btn-cancel, .btn-save {
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
cursor: pointer;
border: none;
transition: all 150ms;
}
.btn-cancel {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.btn-cancel:hover {
background: var(--color-bg-elevated);
}
.btn-save {
background: var(--nord10);
color: white;
}
.btn-save:hover {
background: var(--nord9);
}
.btn-save:disabled {
opacity: 0.5;
cursor: default;
}
</style>