diff --git a/src/client/components/CategoryHeader.tsx b/src/client/components/CategoryHeader.tsx
index 726999e..fff2e49 100644
--- a/src/client/components/CategoryHeader.tsx
+++ b/src/client/components/CategoryHeader.tsx
@@ -1,143 +1,139 @@
import { useState } from "react";
import { formatWeight, formatPrice } from "../lib/formatters";
import { useUpdateCategory, useDeleteCategory } from "../hooks/useCategories";
+import { LucideIcon } from "../lib/iconData";
+import { IconPicker } from "./IconPicker";
interface CategoryHeaderProps {
- categoryId: number;
- name: string;
- emoji: string;
- totalWeight: number;
- totalCost: number;
- itemCount: number;
+ categoryId: number;
+ name: string;
+ icon: string;
+ totalWeight: number;
+ totalCost: number;
+ itemCount: number;
}
export function CategoryHeader({
- categoryId,
- name,
- emoji,
- totalWeight,
- totalCost,
- itemCount,
+ categoryId,
+ name,
+ icon,
+ totalWeight,
+ totalCost,
+ itemCount,
}: CategoryHeaderProps) {
- const [isEditing, setIsEditing] = useState(false);
- const [editName, setEditName] = useState(name);
- const [editEmoji, setEditEmoji] = useState(emoji);
- const updateCategory = useUpdateCategory();
- const deleteCategory = useDeleteCategory();
+ const [isEditing, setIsEditing] = useState(false);
+ const [editName, setEditName] = useState(name);
+ const [editIcon, setEditIcon] = useState(icon);
+ const updateCategory = useUpdateCategory();
+ const deleteCategory = useDeleteCategory();
- const isUncategorized = categoryId === 1;
+ const isUncategorized = categoryId === 1;
- function handleSave() {
- if (!editName.trim()) return;
- updateCategory.mutate(
- { id: categoryId, name: editName.trim(), emoji: editEmoji },
- { onSuccess: () => setIsEditing(false) },
- );
- }
+ function handleSave() {
+ if (!editName.trim()) return;
+ updateCategory.mutate(
+ { id: categoryId, name: editName.trim(), icon: editIcon },
+ { onSuccess: () => setIsEditing(false) },
+ );
+ }
- function handleDelete() {
- if (
- confirm(`Delete category "${name}"? Items will be moved to Uncategorized.`)
- ) {
- deleteCategory.mutate(categoryId);
- }
- }
+ function handleDelete() {
+ if (
+ confirm(`Delete category "${name}"? Items will be moved to Uncategorized.`)
+ ) {
+ deleteCategory.mutate(categoryId);
+ }
+ }
- if (isEditing) {
- return (
-
- setEditEmoji(e.target.value)}
- className="w-12 text-center text-xl border border-gray-200 rounded-md px-1 py-1"
- maxLength={4}
- />
- setEditName(e.target.value)}
- className="text-lg font-semibold border border-gray-200 rounded-md px-2 py-1"
- onKeyDown={(e) => {
- if (e.key === "Enter") handleSave();
- if (e.key === "Escape") setIsEditing(false);
- }}
- autoFocus
- />
-
-
-
- );
- }
+ if (isEditing) {
+ return (
+
+
+ setEditName(e.target.value)}
+ className="text-lg font-semibold border border-gray-200 rounded-md px-2 py-1"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleSave();
+ if (e.key === "Escape") setIsEditing(false);
+ }}
+ autoFocus
+ />
+
+
+
+ );
+ }
- return (
-
-
{emoji}
-
{name}
-
- {itemCount} {itemCount === 1 ? "item" : "items"} ·{" "}
- {formatWeight(totalWeight)} · {formatPrice(totalCost)}
-
- {!isUncategorized && (
-
-
-
-
- )}
-
- );
+ return (
+
+
+
{name}
+
+ {itemCount} {itemCount === 1 ? "item" : "items"} ·{" "}
+ {formatWeight(totalWeight)} · {formatPrice(totalCost)}
+
+ {!isUncategorized && (
+
+
+
+
+ )}
+
+ );
}
diff --git a/src/client/components/CategoryPicker.tsx b/src/client/components/CategoryPicker.tsx
index 6d9f6d9..8a43774 100644
--- a/src/client/components/CategoryPicker.tsx
+++ b/src/client/components/CategoryPicker.tsx
@@ -1,200 +1,263 @@
-import { useState, useRef, useEffect } from "react";
+import { useEffect, useRef, useState } from "react";
import {
- useCategories,
- useCreateCategory,
+ useCategories,
+ useCreateCategory,
} from "../hooks/useCategories";
+import { LucideIcon } from "../lib/iconData";
+import { IconPicker } from "./IconPicker";
interface CategoryPickerProps {
- value: number;
- onChange: (categoryId: number) => void;
+ value: number;
+ onChange: (categoryId: number) => void;
}
export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
- const { data: categories = [] } = useCategories();
- const createCategory = useCreateCategory();
- const [inputValue, setInputValue] = useState("");
- const [isOpen, setIsOpen] = useState(false);
- const [highlightIndex, setHighlightIndex] = useState(-1);
- const containerRef = useRef(null);
- const inputRef = useRef(null);
- const listRef = useRef(null);
+ const { data: categories = [] } = useCategories();
+ const createCategory = useCreateCategory();
+ const [inputValue, setInputValue] = useState("");
+ const [isOpen, setIsOpen] = useState(false);
+ const [highlightIndex, setHighlightIndex] = useState(-1);
+ const [isCreating, setIsCreating] = useState(false);
+ const [newCategoryIcon, setNewCategoryIcon] = useState("package");
+ const containerRef = useRef(null);
+ const inputRef = useRef(null);
+ const listRef = useRef(null);
- // Sync display value when value prop changes
- const selectedCategory = categories.find((c) => c.id === value);
+ // Sync display value when value prop changes
+ const selectedCategory = categories.find((c) => c.id === value);
- const filtered = categories.filter((c) =>
- c.name.toLowerCase().includes(inputValue.toLowerCase()),
- );
+ const filtered = categories.filter((c) =>
+ c.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
- const showCreateOption =
- inputValue.trim() !== "" &&
- !categories.some(
- (c) => c.name.toLowerCase() === inputValue.trim().toLowerCase(),
- );
+ const showCreateOption =
+ inputValue.trim() !== "" &&
+ !categories.some(
+ (c) => c.name.toLowerCase() === inputValue.trim().toLowerCase(),
+ );
- const totalOptions = filtered.length + (showCreateOption ? 1 : 0);
+ const totalOptions = filtered.length + (showCreateOption ? 1 : 0);
- useEffect(() => {
- function handleClickOutside(e: MouseEvent) {
- if (
- containerRef.current &&
- !containerRef.current.contains(e.target as Node)
- ) {
- setIsOpen(false);
- // Reset input to selected category name
- if (selectedCategory) {
- setInputValue("");
- }
- }
- }
- document.addEventListener("mousedown", handleClickOutside);
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, [selectedCategory]);
+ useEffect(() => {
+ function handleClickOutside(e: MouseEvent) {
+ const target = e.target as Node;
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(target) &&
+ !(target instanceof Element && target.closest("[data-icon-picker]"))
+ ) {
+ setIsOpen(false);
+ setIsCreating(false);
+ setNewCategoryIcon("package");
+ // Reset input to selected category name
+ if (selectedCategory) {
+ setInputValue("");
+ }
+ }
+ }
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, [selectedCategory]);
- function handleSelect(categoryId: number) {
- onChange(categoryId);
- setInputValue("");
- setIsOpen(false);
- setHighlightIndex(-1);
- }
+ function handleSelect(categoryId: number) {
+ onChange(categoryId);
+ setInputValue("");
+ setIsOpen(false);
+ setHighlightIndex(-1);
+ }
- async function handleCreate() {
- const name = inputValue.trim();
- if (!name) return;
- createCategory.mutate(
- { name, emoji: "\u{1F4E6}" },
- {
- onSuccess: (newCat) => {
- handleSelect(newCat.id);
- },
- },
- );
- }
+ function handleStartCreate() {
+ setIsCreating(true);
+ }
- function handleKeyDown(e: React.KeyboardEvent) {
- if (!isOpen) {
- if (e.key === "ArrowDown" || e.key === "Enter") {
- setIsOpen(true);
- e.preventDefault();
- }
- return;
- }
+ async function handleConfirmCreate() {
+ const name = inputValue.trim();
+ if (!name) return;
+ createCategory.mutate(
+ { name, icon: newCategoryIcon },
+ {
+ onSuccess: (newCat) => {
+ setIsCreating(false);
+ setNewCategoryIcon("package");
+ handleSelect(newCat.id);
+ },
+ },
+ );
+ }
- switch (e.key) {
- case "ArrowDown":
- e.preventDefault();
- setHighlightIndex((i) => Math.min(i + 1, totalOptions - 1));
- break;
- case "ArrowUp":
- e.preventDefault();
- setHighlightIndex((i) => Math.max(i - 1, 0));
- break;
- case "Enter":
- e.preventDefault();
- if (highlightIndex >= 0 && highlightIndex < filtered.length) {
- handleSelect(filtered[highlightIndex].id);
- } else if (
- showCreateOption &&
- highlightIndex === filtered.length
- ) {
- handleCreate();
- }
- break;
- case "Escape":
- setIsOpen(false);
- setHighlightIndex(-1);
- setInputValue("");
- break;
- }
- }
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if (!isOpen) {
+ if (e.key === "ArrowDown" || e.key === "Enter") {
+ setIsOpen(true);
+ e.preventDefault();
+ }
+ return;
+ }
- // Scroll highlighted option into view
- useEffect(() => {
- if (highlightIndex >= 0 && listRef.current) {
- const option = listRef.current.children[highlightIndex] as HTMLElement;
- option?.scrollIntoView({ block: "nearest" });
- }
- }, [highlightIndex]);
+ switch (e.key) {
+ case "ArrowDown":
+ e.preventDefault();
+ setHighlightIndex((i) => Math.min(i + 1, totalOptions - 1));
+ break;
+ case "ArrowUp":
+ e.preventDefault();
+ setHighlightIndex((i) => Math.max(i - 1, 0));
+ break;
+ case "Enter":
+ e.preventDefault();
+ if (isCreating) {
+ handleConfirmCreate();
+ } else if (highlightIndex >= 0 && highlightIndex < filtered.length) {
+ handleSelect(filtered[highlightIndex].id);
+ } else if (
+ showCreateOption &&
+ highlightIndex === filtered.length
+ ) {
+ handleStartCreate();
+ }
+ break;
+ case "Escape":
+ if (isCreating) {
+ setIsCreating(false);
+ setNewCategoryIcon("package");
+ } else {
+ setIsOpen(false);
+ setHighlightIndex(-1);
+ setInputValue("");
+ }
+ break;
+ }
+ }
- return (
-
-
= 0 ? `category-option-${highlightIndex}` : undefined
- }
- value={
- isOpen
- ? inputValue
- : selectedCategory
- ? `${selectedCategory.emoji} ${selectedCategory.name}`
- : ""
- }
- placeholder="Search or create category..."
- onChange={(e) => {
- setInputValue(e.target.value);
- setIsOpen(true);
- setHighlightIndex(-1);
- }}
- onFocus={() => {
- setIsOpen(true);
- setInputValue("");
- }}
- onKeyDown={handleKeyDown}
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
- />
- {isOpen && (
-
- {filtered.map((cat, i) => (
- - handleSelect(cat.id)}
- onMouseEnter={() => setHighlightIndex(i)}
- >
- {cat.emoji} {cat.name}
-
- ))}
- {showCreateOption && (
- - setHighlightIndex(filtered.length)}
- >
- + Create "{inputValue.trim()}"
-
- )}
- {filtered.length === 0 && !showCreateOption && (
- -
- No categories found
-
- )}
-
- )}
-
- );
+ // Scroll highlighted option into view
+ useEffect(() => {
+ if (highlightIndex >= 0 && listRef.current) {
+ const option = listRef.current.children[highlightIndex] as HTMLElement;
+ option?.scrollIntoView({ block: "nearest" });
+ }
+ }, [highlightIndex]);
+
+ return (
+
+
+ {!isOpen && selectedCategory && (
+
+
+
+ )}
+
= 0
+ ? `category-option-${highlightIndex}`
+ : undefined
+ }
+ value={
+ isOpen
+ ? inputValue
+ : selectedCategory
+ ? selectedCategory.name
+ : ""
+ }
+ placeholder="Search or create category..."
+ onChange={(e) => {
+ setInputValue(e.target.value);
+ setIsOpen(true);
+ setHighlightIndex(-1);
+ }}
+ onFocus={() => {
+ setIsOpen(true);
+ setInputValue("");
+ }}
+ onKeyDown={handleKeyDown}
+ className={`w-full py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
+ !isOpen && selectedCategory ? "pl-8 pr-3" : "px-3"
+ }`}
+ />
+
+ {isOpen && (
+
+ )}
+
+ );
}
diff --git a/src/client/components/CreateThreadModal.tsx b/src/client/components/CreateThreadModal.tsx
index 73d3404..1933ce6 100644
--- a/src/client/components/CreateThreadModal.tsx
+++ b/src/client/components/CreateThreadModal.tsx
@@ -112,7 +112,7 @@ export function CreateThreadModal() {
>
{categories?.map((cat) => (
))}
diff --git a/src/client/components/OnboardingWizard.tsx b/src/client/components/OnboardingWizard.tsx
index a77d528..81f6cf6 100644
--- a/src/client/components/OnboardingWizard.tsx
+++ b/src/client/components/OnboardingWizard.tsx
@@ -2,6 +2,7 @@ import { useState } from "react";
import { useCreateCategory } from "../hooks/useCategories";
import { useCreateItem } from "../hooks/useItems";
import { useUpdateSetting } from "../hooks/useSettings";
+import { IconPicker } from "./IconPicker";
interface OnboardingWizardProps {
onComplete: () => void;
@@ -12,7 +13,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
// Step 2 state
const [categoryName, setCategoryName] = useState("");
- const [categoryEmoji, setCategoryEmoji] = useState("");
+ const [categoryIcon, setCategoryIcon] = useState("");
const [categoryError, setCategoryError] = useState("");
const [createdCategoryId, setCreatedCategoryId] = useState(null);
@@ -41,7 +42,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
}
setCategoryError("");
createCategory.mutate(
- { name, emoji: categoryEmoji.trim() || undefined },
+ { name, icon: categoryIcon.trim() || undefined },
{
onSuccess: (created) => {
setCreatedCategoryId(created.id);
@@ -164,20 +165,13 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
-
diff --git a/src/client/routes/collection/index.tsx b/src/client/routes/collection/index.tsx
index b87f999..12b07a7 100644
--- a/src/client/routes/collection/index.tsx
+++ b/src/client/routes/collection/index.tsx
@@ -98,7 +98,7 @@ function CollectionView() {
// Group items by categoryId
const groupedItems = new Map<
number,
- { items: typeof items; categoryName: string; categoryEmoji: string }
+ { items: typeof items; categoryName: string; categoryIcon: string }
>();
for (const item of items) {
@@ -109,7 +109,7 @@ function CollectionView() {
groupedItems.set(item.categoryId, {
items: [item],
categoryName: item.categoryName,
- categoryEmoji: item.categoryEmoji,
+ categoryIcon: item.categoryIcon,
});
}
}
@@ -134,7 +134,7 @@ function CollectionView() {
{Array.from(groupedItems.entries()).map(
([
categoryId,
- { items: categoryItems, categoryName, categoryEmoji },
+ { items: categoryItems, categoryName, categoryIcon },
]) => {
const catTotals = categoryTotalsMap.get(categoryId);
return (
@@ -142,7 +142,7 @@ function CollectionView() {
))}
@@ -268,7 +268,7 @@ function PlanningView() {
{categories?.map((cat) => (
))}
@@ -356,7 +356,7 @@ function PlanningView() {
createdAt={thread.createdAt}
status={thread.status}
categoryName={thread.categoryName}
- categoryEmoji={thread.categoryEmoji}
+ categoryIcon={thread.categoryIcon}
/>
))}
diff --git a/src/client/routes/setups/$setupId.tsx b/src/client/routes/setups/$setupId.tsx
index 9bad901..13af796 100644
--- a/src/client/routes/setups/$setupId.tsx
+++ b/src/client/routes/setups/$setupId.tsx
@@ -66,7 +66,7 @@ function SetupDetailPage() {
{
items: typeof setup.items;
categoryName: string;
- categoryEmoji: string;
+ categoryIcon: string;
}
>();
@@ -78,7 +78,7 @@ function SetupDetailPage() {
groupedItems.set(item.categoryId, {
items: [item],
categoryName: item.categoryName,
- categoryEmoji: item.categoryEmoji,
+ categoryIcon: item.categoryIcon,
});
}
}
@@ -177,7 +177,7 @@ function SetupDetailPage() {
{Array.from(groupedItems.entries()).map(
([
categoryId,
- { items: categoryItems, categoryName, categoryEmoji },
+ { items: categoryItems, categoryName, categoryIcon },
]) => {
const catWeight = categoryItems.reduce(
(sum, item) => sum + (item.weightGrams ?? 0),
@@ -192,7 +192,7 @@ function SetupDetailPage() {
removeItem.mutate(item.id)}
/>
diff --git a/src/client/routes/threads/$threadId.tsx b/src/client/routes/threads/$threadId.tsx
index 11313b6..d285526 100644
--- a/src/client/routes/threads/$threadId.tsx
+++ b/src/client/routes/threads/$threadId.tsx
@@ -134,7 +134,7 @@ function ThreadDetailPage() {
weightGrams={candidate.weightGrams}
priceCents={candidate.priceCents}
categoryName={candidate.categoryName}
- categoryEmoji={candidate.categoryEmoji}
+ categoryIcon={candidate.categoryIcon}
imageFilename={candidate.imageFilename}
threadId={threadId}
isActive={isActive}