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 && ( +
    + {filtered.map((cat, i) => ( +
  • handleSelect(cat.id)} + onMouseEnter={() => setHighlightIndex(i)} + > + + {cat.name} +
  • + ))} + {showCreateOption && !isCreating && ( +
  • setHighlightIndex(filtered.length)} + > + + Create "{inputValue.trim()}" +
  • + )} + {isCreating && ( +
  • +
    + + + {inputValue.trim()} + + +
    +
  • + )} + {filtered.length === 0 && !showCreateOption && ( +
  • + No categories found +
  • + )} +
+ )} +
+ ); } 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}