feat: search, category filter, and tag-input UX for admin Ingredients page (v0.87.0)
With ~137 seeded entries the flat unfiltered list stopped being usable. Added a search box (name + alias, case/accent-insensitive), a category filter dropdown with live per-category counts, aliases entered as removable tag chips instead of one comma-separated field, and the category field is now a GROCERY_CATEGORIES dropdown instead of free text (was a typo away from creating an unmatched-anywhere category). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
|
||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||
|
||||
## 0.87.0 — 2026-07-24 21:50
|
||||
|
||||
### Added
|
||||
- Admin → Ingredients: search box (name + alias), category filter with live counts, aliases entered as removable tag chips, and category picked from a dropdown instead of free text — needed once the list grew past a handful of entries.
|
||||
|
||||
## 0.86.0 — 2026-07-24 21:30
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
|
||||
| Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — |
|
||||
| Pantry manual CRUD | Exists (extended 2026-07-24, twice) | Full edit dialog added (previously add+delete only, despite the API already supporting `PUT`) — name/quantity/unit/expiry plus **notes** (free text) and **category** (same `GROCERY_CATEGORIES` slugs shopping lists use; edit dialog's category dropdown initially displayed the raw slug/`__other__` instead of the translated label — fixed by passing a value→label render function to `SelectValue`, same pattern already used in `shopping-list-view.tsx`'s sort dropdown). List always renders all 9 category sections (8 `GROCERY_CATEGORIES` + Other) as collapsible groups, even empty ones — not just categories that currently have items — and items can be dragged between sections (dnd-kit `useDraggable`/`useDroppable`, cross-category drop only; no within-category manual reorder since pantry items have no `sortOrder` column to persist one). An "Auto-categorize" action (same `guessAisle` heuristic as the shopping list) fills in categories for uncategorized items in one click. Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow). | `apps/web/app/api/v1/pantry/**`, `apps/web/components/meal-plan/pantry-manager.tsx`, `apps/web/components/pantry/pantry-item-dialog.tsx` |
|
||||
| Ingredient alias matching (new 2026-07-24, expanded same day) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~137 bilingual EN/FR entries across all 8 grocery categories (produce, dairy/eggs, meat/seafood, bakery, frozen, pantry, spices/condiments, beverages — `packages/db/src/seed.ts`) and wired into every place that compares ingredient names by raw text: pantry add/edit (sets `ingredientId` when a name/alias matches), the can-cook / "use it up soon" scorer, auto-deduct-on-cook, and shopping-list pantry-awareness on generation. Comparison is case- **and accent-insensitive** (`café`/`Café`/`cafe` all equal — NFD-normalize + strip combining marks) in both the alias resolver and the older plain-name fallback matcher (`pantry-shopping-match.ts`'s `normalizeName`, used for the reliable-ingredientId-or-name-fallback path). Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. Growing the list: Admin → Ingredients (UI) or editing `packages/db/src/seed.ts` + `pnpm db:seed` (idempotent). | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` |
|
||||
| Admin: Ingredients CRUD (new 2026-07-24) | Exists | Admin-only page to manage canonical ingredients + aliases without a code change/redeploy — previously the seed file was the only place to add them. Create/edit/delete; name is unique (409 on collision); deleting an ingredient just unlinks any pantry item/recipe ingredient that pointed at it (`ingredientId` is `onDelete: "set null"` on both), nothing else is deleted. | `apps/web/app/admin/ingredients/page.tsx`, `apps/web/app/api/v1/admin/ingredients/**`, `apps/web/components/admin/ingredients-manager.tsx` |
|
||||
| Admin: Ingredients CRUD (new 2026-07-24, UI polished same day) | Exists | Admin-only page to manage canonical ingredients + aliases without a code change/redeploy — previously the seed file was the only place to add them. With ~137 seeded entries a flat unfiltered list stopped being usable, so it also got: a search box (name + alias, case/accent-insensitive), a category filter dropdown with live per-category counts, aliases shown as chips instead of a truncated comma string, and the category field is a `GROCERY_CATEGORIES` `Select` (was free-text, so a typo'd category silently created an unmatched-anywhere bucket) with aliases entered via a tag input (type + Enter, mirrors `edit-collection-dialog.tsx`'s tag UX) instead of one error-prone comma-separated field. Create/edit/delete; name is unique (409 on collision); deleting an ingredient just unlinks any pantry item/recipe ingredient that pointed at it (`ingredientId` is `onDelete: "set null"` on both), nothing else is deleted. | `apps/web/app/admin/ingredients/page.tsx`, `apps/web/app/api/v1/admin/ingredients/**`, `apps/web/components/admin/ingredients-manager.tsx` |
|
||||
| Merge duplicate pantry items (new 2026-07-24, relaxed same day) | Exists | One-shot cleanup for pre-existing pantry rows that are the same ingredient under different names (added before alias matching existed) — groups by resolved ingredient key **alone** (unit is deliberately not part of the grouping key, so two rows merge even with mismatched, missing, or differently-unit'd quantities); sums quantities only when every row in a group has a parseable quantity **and** the same unit, otherwise keeps the first known (quantity, unit) pair rather than guessing. Concatenates notes, keeps the soonest expiry. Manual trigger (a button in the pantry toolbar), not automatic — manual single-item add still always inserts a new row rather than silently merging, since two batches of the same ingredient can have different expiry dates worth tracking separately. | `apps/web/app/api/v1/pantry/merge-duplicates/route.ts` |
|
||||
| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. Matching now goes through the ingredient-alias resolver, not a raw name string compare. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` |
|
||||
| Cook log edit/delete (new 2026-07-24) | Exists | Plain (non-batch) cook-log entries can now be listed, edited (date/servings/notes), and removed — previously log-once, no way to fix a mistake or remove a duplicate entry. The recipe page's "Cooked N times" text is a hover tooltip (up to 8 dates, "+N more" beyond) that also opens a full manage sheet on click. Editing/deleting never touches pantry quantities — a deduction from when the entry was created isn't reversed or reapplied. | `apps/web/app/api/v1/recipes/[id]/cooked/[logId]/route.ts`, `apps/web/components/recipe/{mark-cooked-section,edit-cook-log-dialog}.tsx` |
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2, Tag } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, Tag, X, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,6 +32,20 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { GROCERY_CATEGORIES, type GroceryCategory } from "@/lib/grocery-categories";
|
||||
|
||||
const CATEGORY_LABELS: Record<GroceryCategory, string> = {
|
||||
produce: "Produce",
|
||||
dairyEggs: "Dairy & Eggs",
|
||||
meatSeafood: "Meat & Seafood",
|
||||
bakery: "Bakery",
|
||||
frozen: "Frozen",
|
||||
pantry: "Pantry",
|
||||
spicesCondiments: "Spices & Condiments",
|
||||
beverages: "Beverages",
|
||||
};
|
||||
const UNCATEGORIZED = "__uncategorized__";
|
||||
const ALL_CATEGORIES = "__all__";
|
||||
|
||||
type Ingredient = {
|
||||
id: string;
|
||||
@@ -32,8 +54,66 @@ type Ingredient = {
|
||||
category: string | null;
|
||||
};
|
||||
|
||||
function parseAliases(input: string): string[] {
|
||||
return [...new Set(input.split(",").map((a) => a.trim()).filter(Boolean))];
|
||||
// Same case/accent-insensitive intent as lib/ingredient-match.ts's
|
||||
// normalize() — duplicated rather than imported, since that module pulls
|
||||
// in the DB client (server-only) and can't be bundled for the browser.
|
||||
function normalize(s: string): string {
|
||||
return s.trim().toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
|
||||
}
|
||||
|
||||
function categoryLabel(category: string | null): string {
|
||||
if (!category) return "Uncategorized";
|
||||
return CATEGORY_LABELS[category as GroceryCategory] ?? category;
|
||||
}
|
||||
|
||||
function AliasTagInput({ aliases, onChange }: { aliases: string[]; onChange: (aliases: string[]) => void }) {
|
||||
const [input, setInput] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function addAlias(raw: string) {
|
||||
const alias = raw.trim();
|
||||
if (!alias || aliases.some((a) => normalize(a) === normalize(alias)) || aliases.length >= 50) return;
|
||||
onChange([...aliases, alias]);
|
||||
setInput("");
|
||||
}
|
||||
|
||||
function removeAlias(alias: string) {
|
||||
onChange(aliases.filter((a) => a !== alias));
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addAlias(input);
|
||||
} else if (e.key === "Backspace" && !input && aliases.length > 0) {
|
||||
onChange(aliases.slice(0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5 min-h-9 rounded-lg border border-input bg-transparent px-2.5 py-1.5 cursor-text focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{aliases.map((alias) => (
|
||||
<span key={alias} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs bg-muted text-muted-foreground">
|
||||
{alias}
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); removeAlias(alias); }} className="hover:text-foreground transition-colors" aria-label={`Remove ${alias}`}>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => { if (input.trim()) addAlias(input); }}
|
||||
placeholder={aliases.length === 0 ? "Type an alias, press Enter…" : ""}
|
||||
className="flex-1 min-w-[120px] bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IngredientDialog({
|
||||
@@ -48,15 +128,14 @@ function IngredientDialog({
|
||||
onSaved: (ingredient: Ingredient) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(ingredient?.name ?? "");
|
||||
const [aliasesText, setAliasesText] = useState(ingredient?.aliases.join(", ") ?? "");
|
||||
const [category, setCategory] = useState(ingredient?.category ?? "");
|
||||
const [aliases, setAliases] = useState<string[]>(ingredient?.aliases ?? []);
|
||||
const [category, setCategory] = useState<string>(ingredient?.category ?? UNCATEGORIZED);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isEdit = !!ingredient;
|
||||
|
||||
async function handleSave() {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) return;
|
||||
const aliases = parseAliases(aliasesText);
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
@@ -64,7 +143,7 @@ function IngredientDialog({
|
||||
{
|
||||
method: isEdit ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: trimmedName, aliases, category: category.trim() || null }),
|
||||
body: JSON.stringify({ name: trimmedName, aliases, category: category === UNCATEGORIZED ? null : category }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
@@ -74,7 +153,7 @@ function IngredientDialog({
|
||||
}
|
||||
toast.success(isEdit ? "Ingredient updated" : "Ingredient created");
|
||||
const id = isEdit ? ingredient.id : ((await res.json()) as { id: string }).id;
|
||||
onSaved({ id, name: trimmedName, aliases, category: category.trim() || null });
|
||||
onSaved({ id, name: trimmedName, aliases, category: category === UNCATEGORIZED ? null : category });
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -93,20 +172,25 @@ function IngredientDialog({
|
||||
<Input id="ingredient-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. salt" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ingredient-aliases">Aliases (comma-separated)</Label>
|
||||
<Input
|
||||
id="ingredient-aliases"
|
||||
value={aliasesText}
|
||||
onChange={(e) => setAliasesText(e.target.value)}
|
||||
placeholder="e.g. sel, sel fin, sel de table, table salt"
|
||||
/>
|
||||
<Label>Aliases</Label>
|
||||
<AliasTagInput aliases={aliases} onChange={setAliases} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Matched case-insensitively against pantry item and recipe ingredient names.
|
||||
Matched case- and accent-insensitively against pantry item and recipe ingredient names.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ingredient-category">Category (optional)</Label>
|
||||
<Input id="ingredient-category" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="e.g. spicesCondiments" />
|
||||
<Label>Category</Label>
|
||||
<Select value={category} onValueChange={(v) => setCategory(v ?? UNCATEGORIZED)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{(v: string) => categoryLabel(v === UNCATEGORIZED ? null : v)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNCATEGORIZED}>Uncategorized</SelectItem>
|
||||
{GROCERY_CATEGORIES.map((c) => (
|
||||
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@@ -124,6 +208,8 @@ function IngredientDialog({
|
||||
|
||||
export function IngredientsManager({ initialIngredients }: { initialIngredients: Ingredient[] }) {
|
||||
const [ingredients, setIngredients] = useState<Ingredient[]>(initialIngredients);
|
||||
const [query, setQuery] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>(ALL_CATEGORIES);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingIngredient, setEditingIngredient] = useState<Ingredient | null>(null);
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
@@ -148,33 +234,85 @@ export function IngredientsManager({ initialIngredients }: { initialIngredients:
|
||||
}
|
||||
}
|
||||
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const i of ingredients) {
|
||||
const key = i.category ?? UNCATEGORIZED;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [ingredients]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = normalize(query);
|
||||
return ingredients
|
||||
.filter((i) => categoryFilter === ALL_CATEGORIES || (i.category ?? UNCATEGORIZED) === categoryFilter)
|
||||
.filter((i) => {
|
||||
if (!q) return true;
|
||||
if (normalize(i.name).includes(q)) return true;
|
||||
return i.aliases.some((a) => normalize(a).includes(q));
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [ingredients, query, categoryFilter]);
|
||||
|
||||
const pendingDelete = ingredients.find((i) => i.id === confirmId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search name or alias…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={categoryFilter} onValueChange={(v) => setCategoryFilter(v ?? ALL_CATEGORIES)}>
|
||||
<SelectTrigger className="w-44 shrink-0">
|
||||
<SelectValue>{(v: string) => (v === ALL_CATEGORIES ? "All categories" : categoryLabel(v === UNCATEGORIZED ? null : v))}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_CATEGORIES}>All categories ({ingredients.length})</SelectItem>
|
||||
<SelectItem value={UNCATEGORIZED}>Uncategorized ({categoryCounts.get(UNCATEGORIZED) ?? 0})</SelectItem>
|
||||
{GROCERY_CATEGORIES.map((c) => (
|
||||
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]} ({categoryCounts.get(c) ?? 0})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreate} className="shrink-0">
|
||||
<Plus className="h-4 w-4" /> New ingredient
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{filtered.length === ingredients.length
|
||||
? `${ingredients.length} ingredient${ingredients.length === 1 ? "" : "s"}`
|
||||
: `${filtered.length} of ${ingredients.length} ingredients`}
|
||||
</p>
|
||||
|
||||
{ingredients.length === 0 ? (
|
||||
<EmptyState icon={Tag} title="No ingredients yet" description="Add a canonical ingredient and its common name variants." compact />
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">No ingredients match your search.</p>
|
||||
) : (
|
||||
<div className="rounded-xl border divide-y">
|
||||
{ingredients.map((ingredient) => (
|
||||
{filtered.map((ingredient) => (
|
||||
<div key={ingredient.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm">{ingredient.name}</span>
|
||||
{ingredient.category && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground bg-muted rounded px-1.5 py-0.5">
|
||||
{ingredient.category}
|
||||
</span>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">{categoryLabel(ingredient.category)}</Badge>
|
||||
</div>
|
||||
{ingredient.aliases.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{ingredient.aliases.join(", ")}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{ingredient.aliases.map((alias) => (
|
||||
<span key={alias} className="text-[11px] text-muted-foreground bg-muted rounded px-1.5 py-0.5">{alias}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => openEdit(ingredient)} aria-label="Edit" className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.86.0";
|
||||
export const APP_VERSION = "0.87.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.87.0",
|
||||
date: "2026-07-24 21:50",
|
||||
added: [
|
||||
"Admin > Ingredients: search box (name + alias), category filter with live counts, aliases entered as removable tag chips, and category picked from a dropdown instead of free text — needed once the list grew past a handful of entries.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.86.0",
|
||||
date: "2026-07-24 21:30",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.86.0",
|
||||
"version": "0.87.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.86.0",
|
||||
"version": "0.87.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user