From c71bc1830a676b89a1d9af1321db72d7caf98811 Mon Sep 17 00:00:00 2001 From: Radison Akerman Date: Tue, 21 Oct 2025 18:26:24 -0700 Subject: [PATCH 1/3] feat: item selector and actions --- next/package.json | 2 - .../src/app/(authenticated)/items/loading.tsx | 83 ++---- next/src/app/(authenticated)/items/page.tsx | 280 ++++++++---------- next/src/components/AddOutfitModal.tsx | 86 ++++-- next/src/components/Item.tsx | 77 ----- next/src/components/ItemActions.tsx | 198 +++++++++++++ next/src/components/ItemDisplay.tsx | 85 ++++++ next/src/components/ItemInlineSearch.tsx | 10 +- next/src/components/ItemList.tsx | 92 ++++-- next/src/components/ItemListContainer.tsx | 84 ++++++ next/src/components/ItemListLoading.tsx | 14 +- next/src/components/ItemLoading.tsx | 24 ++ next/src/components/OutfitCardLoading.tsx | 12 +- next/src/components/OutfitList.tsx | 93 ++++-- next/src/components/OutfitSuggestions.tsx | 71 +++-- next/src/components/SelectableItem.tsx | 256 ++++++++++++++++ next/src/components/SelectableItemList.tsx | 108 +++++++ next/src/components/SelectionToolbar.tsx | 174 +++++++++++ next/src/components/ui/avatar.tsx | 50 ---- next/src/components/ui/badge.tsx | 36 --- next/src/components/ui/command.tsx | 153 ---------- next/src/components/ui/input.tsx | 22 -- next/src/components/ui/label.tsx | 26 -- next/src/components/ui/progress.tsx | 28 -- next/src/components/ui/rating.tsx | 2 +- next/src/components/ui/tabs.tsx | 55 ---- next/src/components/ui/tag.tsx | 16 +- next/src/components/ui/textarea.tsx | 22 -- next/src/lib/hooks/useItemSearch.ts | 2 +- next/src/lib/hooks/useItemSelection.ts | 103 +++++++ next/src/lib/hooks/useLocation.ts | 84 ++---- 31 files changed, 1491 insertions(+), 857 deletions(-) delete mode 100644 next/src/components/Item.tsx create mode 100644 next/src/components/ItemActions.tsx create mode 100644 next/src/components/ItemDisplay.tsx create mode 100644 next/src/components/ItemListContainer.tsx create mode 100644 next/src/components/ItemLoading.tsx create mode 100644 next/src/components/SelectableItem.tsx create mode 100644 next/src/components/SelectableItemList.tsx create mode 100644 next/src/components/SelectionToolbar.tsx delete mode 100644 next/src/components/ui/avatar.tsx delete mode 100644 next/src/components/ui/badge.tsx delete mode 100644 next/src/components/ui/command.tsx delete mode 100644 next/src/components/ui/input.tsx delete mode 100644 next/src/components/ui/label.tsx delete mode 100644 next/src/components/ui/progress.tsx delete mode 100644 next/src/components/ui/tabs.tsx delete mode 100644 next/src/components/ui/textarea.tsx create mode 100644 next/src/lib/hooks/useItemSelection.ts diff --git a/next/package.json b/next/package.json index 64a5666..3fcfb89 100644 --- a/next/package.json +++ b/next/package.json @@ -26,9 +26,7 @@ "@radix-ui/react-tooltip": "^1.1.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "cmdk": "^1.0.0", "date-fns": "^3.6.0", - "framer-motion": "^11.16.1", "hono": "^4.6.18", "lucide-react": "^0.469.0", "luxon": "^3.5.0", diff --git a/next/src/app/(authenticated)/items/loading.tsx b/next/src/app/(authenticated)/items/loading.tsx index 51aacb1..a60d8f4 100644 --- a/next/src/app/(authenticated)/items/loading.tsx +++ b/next/src/app/(authenticated)/items/loading.tsx @@ -1,71 +1,44 @@ -import { Card, CardContent } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { Button } from '@/components/ui/button' -import { Search } from 'lucide-react' +import { ItemLoading } from '@/components/ItemLoading' +import { itemTypeIcons } from '@/components/SelectableItem' export default function ItemsLoading() { - // Create an array of item types to show in the filter bar - const itemTypes = ['top', 'bottom', 'footwear', 'accessory', 'layer'] + const itemTypes = Object.keys(itemTypeIcons) as Array return (
- {/* Type filter bar skeleton with archive toggle */} + {/* Type filter bar */}
- {itemTypes.map((type) => ( - - ))} + {itemTypes.map((type) => { + const Icon = itemTypeIcons[type] + return ( + + ) + })}
-
- {/* Items list skeleton */} - - - {/* Search input skeleton - ItemInlineSearch style */} -
-
-
- -
-
-
- -
-
-
-
-
    - {Array.from({ length: 10 }).map((_, index) => ( -
  • - -
    - - -
    -
  • - ))} -
-
-
+
    + {Array.from({ length: 10 }).map((_, index) => ( +
  • + +
  • + ))} +
) } \ No newline at end of file diff --git a/next/src/app/(authenticated)/items/page.tsx b/next/src/app/(authenticated)/items/page.tsx index f340206..c6ac23e 100644 --- a/next/src/app/(authenticated)/items/page.tsx +++ b/next/src/app/(authenticated)/items/page.tsx @@ -1,29 +1,35 @@ 'use client' -import React, { Suspense } from 'react' +import React, { Suspense, useMemo, useCallback } from 'react' import { useState } from 'react' import { useItems } from '@/lib/client' import { useItemSearch } from '@/lib/hooks/useItemSearch' +import { useItemSelection } from '@/lib/hooks/useItemSelection' import { Card, CardContent } from '@/components/ui/card' -import { Item, itemTypeIcons } from '@/components/Item' +import { itemTypeIcons, SelectableItem } from '@/components/SelectableItem' import { Button } from '@/components/ui/button' -import { Archive, ArchiveRestore, Ban } from 'lucide-react' -import type { ItemStatus } from '@/lib/types' import { ITEM_STATUS } from '@/lib/types' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { ItemInlineSearch } from '@/components/ItemInlineSearch' +import { SelectionToolbar } from '@/components/SelectionToolbar' import ItemsLoading from './loading' // Separate component for Items content to use with Suspense function ItemsContent() { - const { items, isLoading, updateItemStatus, mutate } = useItems() + const { items, isLoading, isError, mutate } = useItems() const [selectedType, setSelectedType] = useState(null) + // Global selection state + const { + selectedItemIds, + isBatchUpdating, + statusChangingItemId, + changingToStatus, + handleToggleSelection, + handleClearSelection, + handleBatchStatusChange, + handleStatusChange, + } = useItemSelection() + + const { searchTerm, addMode, @@ -37,31 +43,34 @@ function ItemsContent() { typeFilter: selectedType }) + // Sort items to show available items first, then withheld, then retired - const filteredItems = [...baseFilteredItems].sort((a, b) => { - // First sort by status (available first, then withheld, then retired) - const statusOrder = { [ITEM_STATUS.AVAILABLE]: 0, [ITEM_STATUS.WITHHELD]: 1, [ITEM_STATUS.RETIRED]: 2 } as const - const statusCompare = statusOrder[a.status as keyof typeof statusOrder] - statusOrder[b.status as keyof typeof statusOrder] - if (statusCompare !== 0) return statusCompare - - // For items with the same status, sort by lastWornAt (oldest first) - if (a.status === ITEM_STATUS.AVAILABLE && b.status === ITEM_STATUS.AVAILABLE) { - // Items without lastWornAt should be first (never worn) - if (!a.lastWornAt && b.lastWornAt) return -1; - if (a.lastWornAt && !b.lastWornAt) return 1; + const filteredItems = useMemo(() => { + return [...baseFilteredItems].sort((a, b) => { + // First sort by status (available first, then withheld, then retired) + const statusOrder = { [ITEM_STATUS.AVAILABLE]: 0, [ITEM_STATUS.WITHHELD]: 1, [ITEM_STATUS.RETIRED]: 2 } as const + const statusCompare = statusOrder[a.status as keyof typeof statusOrder] - statusOrder[b.status as keyof typeof statusOrder] + if (statusCompare !== 0) return statusCompare - // If both have lastWornAt dates, sort oldest first - if (a.lastWornAt && b.lastWornAt) { - return new Date(a.lastWornAt).getTime() - new Date(b.lastWornAt).getTime(); + // For items with the same status, sort by lastWornAt (oldest first) + if (a.status === ITEM_STATUS.AVAILABLE && b.status === ITEM_STATUS.AVAILABLE) { + // Items without lastWornAt should be first (never worn) + if (!a.lastWornAt && b.lastWornAt) return -1; + if (a.lastWornAt && !b.lastWornAt) return 1; + + // If both have lastWornAt dates, sort oldest first + if (a.lastWornAt && b.lastWornAt) { + return new Date(a.lastWornAt).getTime() - new Date(b.lastWornAt).getTime(); + } } - } - - // If status is the same and other criteria don't apply, preserve original order - return 0; - }); + + // If status is the same and other criteria don't apply, preserve original order + return 0; + }); + }, [baseFilteredItems]); // Function to determine which date category an item belongs to - const getDateCategory = (item: typeof filteredItems[0]) => { + const getDateCategory = useCallback((item: typeof filteredItems[0]) => { if (item.status !== ITEM_STATUS.AVAILABLE) return item.status; if (!item.lastWornAt) return 'never'; @@ -83,15 +92,17 @@ function ItemsContent() { // Otherwise, categorize by month-year return `${lastWorn.getFullYear()}-${lastWorn.getMonth()}`; - } + }, []); // Group items by date category - const itemsByCategory = filteredItems.reduce((acc, item) => { - const category = getDateCategory(item); - if (!acc[category]) acc[category] = []; - acc[category].push(item); - return acc; - }, {} as Record); + const itemsByCategory = useMemo(() => { + return filteredItems.reduce((acc, item) => { + const category = getDateCategory(item); + if (!acc[category]) acc[category] = []; + acc[category].push(item); + return acc; + }, {} as Record); + }, [filteredItems, getDateCategory]); // Function to get a human-readable title for a category const getCategoryTitle = (category: string) => { @@ -131,9 +142,11 @@ function ItemsContent() { } // Get categories in sorted order - const sortedCategories = Object.keys(itemsByCategory).sort( - (a, b) => getCategoryOrder(a) - getCategoryOrder(b) - ); + const sortedCategories = useMemo(() => { + return Object.keys(itemsByCategory).sort( + (a, b) => getCategoryOrder(a) - getCategoryOrder(b) + ); + }, [itemsByCategory]); // Function to find the global index of an item in the filtered items list // based on its position within its category @@ -156,6 +169,19 @@ function ItemsContent() { return } + if (isError) { + return ( +
+

+ Failed to load items. Please try again. +

+ +
+ ) + } + const itemTypes = Object.keys(itemTypeIcons) as Array const handleTypeClick = (type: keyof typeof itemTypeIcons) => { @@ -168,9 +194,6 @@ function ItemsContent() { } } - const handleStatusChange = async (itemId: string, newStatus: ItemStatus) => { - await updateItemStatus(itemId, newStatus) - } const handleNewItem = (itemId: string, itemType: string) => { // Call the base handler @@ -185,6 +208,7 @@ function ItemsContent() { } } + return (
{/* Type filter bar */} @@ -215,117 +239,65 @@ function ItemsContent() {
- {/* Items list */} - - - {/* Search input */} -
- {}} - onKeyDown={handleKeyDown} - addMode={addMode} - onNewItem={handleNewItem} - /> -
- {!addMode && filteredItems.length > 0 ? ( -
    - {sortedCategories.map((category, categoryIndex) => { - return ( - -
  • 0 ? 'mt-4' : ''} pb-1`}> -
    - {getCategoryTitle(category)} -
    + {!addMode && filteredItems.length > 0 ? ( +
    + {sortedCategories.map((category, categoryIndex) => { + return ( + +
    +
    + {getCategoryTitle(category)} +
    +
    +
      + {itemsByCategory[category].map((item: any, index: number) => ( +
    • + 0} + onToggleSelection={handleToggleSelection} + // Status change props + isStatusChanging={statusChangingItemId === item.id} + changingToStatus={changingToStatus || undefined} + onStatusChange={handleStatusChange} + />
    • - {itemsByCategory[category].map((item, itemIndex) => { - return ( -
    • - - -
      - -
      -
      - - {item.status === ITEM_STATUS.AVAILABLE && ( - <> - handleStatusChange(item.id, ITEM_STATUS.WITHHELD)} - > - - Withhold Item - - handleStatusChange(item.id, ITEM_STATUS.RETIRED)} - > - - Retire Item - - - )} - {item.status === ITEM_STATUS.WITHHELD && ( - <> - handleStatusChange(item.id, ITEM_STATUS.AVAILABLE)} - > - - Make Available - - handleStatusChange(item.id, ITEM_STATUS.RETIRED)} - > - - Retire Item - - - )} - {item.status === ITEM_STATUS.RETIRED && ( - <> - handleStatusChange(item.id, ITEM_STATUS.AVAILABLE)} - > - - Make Available - - handleStatusChange(item.id, ITEM_STATUS.WITHHELD)} - > - - Withhold Item - - - )} - -
      -
    • - ) - })} - - ) - })} -
    - ) : !addMode ? ( -
    -

    - {searchTerm - ? "No items found matching your search criteria. Keep typing to create a new item." - : "No items found with the selected filter."} -

    -
    - ) : null} - - + ))} +
+ + ) + })} + + ) : !addMode ? ( +
+

+ {searchTerm + ? "No items found matching your search criteria. Keep typing to create a new item." + : "No items found with the selected filter."} +

+
+ ) : null} + + {/* Global Selection toolbar with integrated search */} + selectedItemIds.has(item.id))} + onBatchStatusChange={handleBatchStatusChange} + onClearSelection={handleClearSelection} + isUpdating={isBatchUpdating} + changingToStatus={changingToStatus} + searchValue={searchTerm} + onSearchChange={handleSearchChange} + onSearchClick={() => {}} + onSearchKeyDown={handleKeyDown} + searchAddMode={addMode} + onSearchNewItem={handleNewItem} + /> ) } diff --git a/next/src/components/AddOutfitModal.tsx b/next/src/components/AddOutfitModal.tsx index 02f8bc0..343f475 100644 --- a/next/src/components/AddOutfitModal.tsx +++ b/next/src/components/AddOutfitModal.tsx @@ -9,13 +9,14 @@ import { cn } from "@/lib/utils" import { format } from "date-fns" import { Calendar } from "@/components/ui/calendar" import { ScrollArea } from "@/components/ui/scroll-area" -import { Item, itemTypeIcons } from '@/components/Item' +import { SelectableItem, itemTypeIcons } from '@/components/SelectableItem' import Rating from './ui/rating' import { Tag } from "@/components/ui/tag" import { client, useItems, useTags, useOutfits } from '@/lib/client' import { useAuth } from '@clerk/nextjs' import { ItemInlineSearch } from './ItemInlineSearch' import { useLocation } from '@/lib/hooks/useLocation' +import type { ItemStatus } from '@/lib/types' interface AddOutfitModalProps { open?: boolean @@ -48,7 +49,7 @@ export function AddOutfitModal({ const [showDropdown, setShowDropdown] = useState(false) const searchInputRef = useRef(null) - const { items: allItems, isLoading: isItemsLoading } = useItems() + const { items: allItems, isLoading: isItemsLoading, updateItemStatus, mutate: mutateItems } = useItems() const [selectedItems, setSelectedItems] = useState>([]) const { tags } = useTags() @@ -56,6 +57,10 @@ export function AddOutfitModal({ // Add state for highlighted index const [highlightedIndex, setHighlightedIndex] = useState(-1) + + // Add state for status changes + const [statusChangingItemId, setStatusChangingItemId] = useState(null) + const [changingToStatus, setChangingToStatus] = useState(null) // Add ref for the scroll area const scrollAreaRef = useRef(null) @@ -74,11 +79,6 @@ export function AddOutfitModal({ setSearchTerm('') setShowDropdown(false) - // Request location when modal opens - if (newOpen) { - requestLocation() - } - // Focus the search input when modal opens setTimeout(() => { searchInputRef.current?.focus() @@ -154,6 +154,25 @@ export function AddOutfitModal({ } } + const handleStatusChange = async (itemId: string, newStatus: ItemStatus) => { + setStatusChangingItemId(itemId) + setChangingToStatus(newStatus) + try { + const success = await updateItemStatus(itemId, newStatus) + if (success) { + // Clear loading state immediately after successful API call + setStatusChangingItemId(null) + setChangingToStatus(null) + // Trigger data refetch in background + mutateItems() + } + } catch (error) { + // Clear loading state on error too + setStatusChangingItemId(null) + setChangingToStatus(null) + } + } + const handleTagToggle = (tagId: string) => { // Check if tag is being selected or deselected const isSelecting = !selectedTags.includes(tagId) @@ -295,22 +314,29 @@ export function AddOutfitModal({ const item = allItems?.find(i => i.id === selectedItem.id) if (!item) return null return ( -
+
-
- +
+ +
) })} @@ -362,11 +388,17 @@ export function AddOutfitModal({ onClick={() => handleItemSelect(item.id, item.type)} onMouseEnter={() => setHighlightedIndex(index)} > - +
+ +
{highlightedIndex === index && (
@@ -387,7 +419,7 @@ export function AddOutfitModal({ )}
-
+
@@ -424,6 +456,7 @@ export function AddOutfitModal({ + + + + + + ) + } + + // Popover mode + if (showAsPopover) { + return + } + + // Context menu mode (default) + return ( + + + {children ||
} + + + + + + ) +} diff --git a/next/src/components/ItemDisplay.tsx b/next/src/components/ItemDisplay.tsx new file mode 100644 index 0000000..12ceea6 --- /dev/null +++ b/next/src/components/ItemDisplay.tsx @@ -0,0 +1,85 @@ +'use client' + +import { Layers, Shirt, Footprints, Crown } from 'lucide-react' +import { PiPantsFill } from 'react-icons/pi' +import { DateTime } from "luxon" +import { ItemsResponse } from '@/lib/client' +import { cn } from '@/lib/utils' +import { ITEM_STATUS, ITEM_STATUS_LABELS } from '@/lib/types' +import { itemTypeIcons } from './SelectableItem' + +type ItemType = keyof typeof itemTypeIcons + +interface ItemDisplayProps { + item: ItemsResponse['items'][number] + itemType?: string + isCoreItem?: boolean + showLastWornAt?: boolean + freshness?: number + className?: string +} + +/** + * Pure display component for items in read-only contexts. + * No selection, no actions, just clean item display. + */ +export function ItemDisplay({ + item, + itemType, + isCoreItem = false, + showLastWornAt = false, + freshness, + className +}: ItemDisplayProps) { + const Icon = itemTypeIcons[itemType as ItemType] || Layers + const freshnessDisplay = freshness !== undefined ? `${Math.round(freshness * 100)}%` : null + + return ( +
+
+
+ +
+ +
+
+

+ {item.name} + {isCoreItem && } +

+

+ {item.status !== ITEM_STATUS.AVAILABLE && ( + <> + {ITEM_STATUS_LABELS[item.status as keyof typeof ITEM_STATUS_LABELS]} + + + )} + + {item.brand ? item.brand : Unbranded} + + {showLastWornAt && ( + <> + + {item.lastWornAt ? ( + <>Worn {DateTime.fromISO(item.lastWornAt).toRelativeCalendar({ locale: "en-US", unit: "days" })} + ) : ( + Never worn! + )} + + )} + {freshnessDisplay && ( + <> + + F: {freshnessDisplay} + + )} +

+
+
+
+
+ ) +} diff --git a/next/src/components/ItemInlineSearch.tsx b/next/src/components/ItemInlineSearch.tsx index 0fcf798..2636424 100644 --- a/next/src/components/ItemInlineSearch.tsx +++ b/next/src/components/ItemInlineSearch.tsx @@ -1,4 +1,4 @@ -import { Plus, Search, Layers, Shirt, Crown, X } from 'lucide-react' +import { Plus, Search, Layers, Shirt, Crown, X, Loader2 } from 'lucide-react' import { PiPantsFill } from 'react-icons/pi' import { Footprints } from 'lucide-react' import { useState, useEffect, useRef, useCallback } from 'react' @@ -166,7 +166,11 @@ export function ItemInlineSearch({ disabled={isSubmitting} className="w-full sm:w-auto flex items-center gap-2 px-3 py-1.5 rounded-md bg-secondary hover:bg-secondary/80 disabled:opacity-50 transition-colors" > - + {isSubmitting ? ( + + ) : ( + + )} {label} ))} @@ -195,7 +199,7 @@ export function ItemInlineSearch({ onClick={onClick} onKeyDown={handleInternalKeyDown} placeholder={stage === 'brand' ? "Enter brand (optional)..." : "Search items, brands, types..."} - className="font-medium leading-[18px] bg-transparent focus:outline-none w-full" + className="font-medium leading-[18px] bg-transparent focus:outline-none w-full truncate" />
)} diff --git a/next/src/components/ItemList.tsx b/next/src/components/ItemList.tsx index c1f7cca..29fe348 100644 --- a/next/src/components/ItemList.tsx +++ b/next/src/components/ItemList.tsx @@ -1,41 +1,95 @@ import React from 'react' -import { Item, itemTypeIcons } from '@/components/Item' -import { OutfitsResponse, OutfitSuggestionsResponse, useItems } from '@/lib/client' +import { OutfitsResponse, OutfitSuggestionsResponse, useItems, ItemsResponse } from '@/lib/client' import { ItemListLoading } from './ItemListLoading' +import { ItemDisplay } from './ItemDisplay' +import { SelectableItem } from './SelectableItem' + +type ItemToOutfit = OutfitsResponse['outfits'][number]['itemsToOutfits'][number] | OutfitSuggestionsResponse['suggestions'][number]['itemsToOutfits'][number] interface ItemListProps { - itemsToOutfits: OutfitsResponse['outfits'][number]['itemsToOutfits'] | OutfitSuggestionsResponse['suggestions'][number]['itemsToOutfits'] + itemsToOutfits: ItemToOutfit[] coreItems?: string[] showLastWornAt?: boolean + showThreeDotsMenu?: boolean + onItemView?: (item: ItemsResponse['items'][number]) => void + onItemEdit?: (item: ItemsResponse['items'][number]) => void + onItemDelete?: (item: ItemsResponse['items'][number]) => void + onItemStatusChange?: (itemId: string, status: any) => Promise + statusChangingItemId?: string | null + changingToStatus?: any } -export function ItemList({ itemsToOutfits, coreItems = [], showLastWornAt = false }: ItemListProps) { +/** + * Simple item list for outfit contexts. Uses ItemDisplay for read-only display + * or SelectableItem with three-dots menu when actions are needed. + */ +export function ItemList({ + itemsToOutfits, + coreItems = [], + showLastWornAt = false, + showThreeDotsMenu = false, + onItemView, + onItemEdit, + onItemDelete, + onItemStatusChange, + statusChangingItemId, + changingToStatus +}: ItemListProps) { const { items, isLoading: isLoadingItems } = useItems() if (isLoadingItems) { return } - return ( -
    - {itemsToOutfits.map((itemToOutfit, index) => { - if (!itemToOutfit.itemId || !itemToOutfit.itemType) return null - - const item = items.find(item => item.id === itemToOutfit.itemId) - if (!item) return null + // Transform itemsToOutfits to items with additional data + const transformedItems: (ItemsResponse['items'][number] & { + itemType: string + freshness?: number + })[] = itemsToOutfits + .map((itemToOutfit) => { + if (!itemToOutfit.itemId || !itemToOutfit.itemType) return null + + const item = items.find((item: any) => item.id === itemToOutfit.itemId) + if (!item) return null - return ( -
  • - => item !== null) + + return ( +
      + {transformedItems.map((item, index) => ( +
    • + {showThreeDotsMenu ? ( + + ) : ( + -
    • - ) - })} + )} + + ))}
    ) } diff --git a/next/src/components/ItemListContainer.tsx b/next/src/components/ItemListContainer.tsx new file mode 100644 index 0000000..8b088e5 --- /dev/null +++ b/next/src/components/ItemListContainer.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { SelectableItem, itemTypeIcons } from '@/components/SelectableItem' +import { useItems } from '@/lib/client' +import type { ItemsResponse } from '@/lib/client' +import type { ItemStatus } from '@/lib/types' + +type ItemType = keyof typeof itemTypeIcons + +/** + * @deprecated Use SelectableItemList instead for better abstraction and cleaner API + */ + +interface ItemListContainerProps { + items: ItemsResponse['items'] | (ItemsResponse['items'][number] & { itemType?: string; freshness?: number })[] + coreItems?: string[] + showLastWornAt?: boolean + renderItem?: (item: ItemsResponse['items'][number], index: number) => React.ReactNode + className?: string + // Selection control + enableSelection?: boolean + // Selection props + selectedItemIds?: Set + isBatchUpdating?: boolean + statusChangingItemId?: string | null + changingToStatus?: ItemStatus | null + handleToggleSelection?: (itemId: string) => void + handleClearSelection?: () => void + handleBatchStatusChange?: (status: any) => Promise + handleStatusChange?: (itemId: string, status: any) => Promise +} + +export function ItemListContainer({ + items, + coreItems = [], + showLastWornAt = false, + renderItem, + className = "space-y-1", + enableSelection = false, + selectedItemIds = new Set(), + isBatchUpdating = false, + statusChangingItemId = null, + changingToStatus = null, + handleToggleSelection, + handleClearSelection, + handleBatchStatusChange, + handleStatusChange, +}: ItemListContainerProps) { + const { items: allItems, isError } = useItems() + + const defaultRenderItem = (item: any, index: number) => ( +
  • + 0} + onToggleSelection={handleToggleSelection} + isStatusChanging={statusChangingItemId === item.id} + changingToStatus={changingToStatus || undefined} + onStatusChange={handleStatusChange} + /> +
  • + ) + + if (isError) { + return ( +
    +

    Failed to load items

    +
    + ) + } + + return ( +
      + {items.map((item, index) => + renderItem ? renderItem(item, index) : defaultRenderItem(item, index) + )} +
    + ) +} diff --git a/next/src/components/ItemListLoading.tsx b/next/src/components/ItemListLoading.tsx index a7281db..89bbdc8 100644 --- a/next/src/components/ItemListLoading.tsx +++ b/next/src/components/ItemListLoading.tsx @@ -1,16 +1,10 @@ -import { Skeleton } from "@/components/ui/skeleton" +import { ItemLoading } from "./ItemLoading" export function ItemListLoading() { return ( -
    - {[...Array(3)].map((_, index) => ( -
    - -
    - - -
    -
    +
    + {[...Array(4)].map((_, index) => ( + ))}
    ) diff --git a/next/src/components/ItemLoading.tsx b/next/src/components/ItemLoading.tsx new file mode 100644 index 0000000..4b41224 --- /dev/null +++ b/next/src/components/ItemLoading.tsx @@ -0,0 +1,24 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export function ItemLoading() { + return ( +
    + {/* Icon skeleton - matches the item type icon container */} +
    + +
    +
    +
    + {/* Item name skeleton */} + + {/* Item details skeleton */} +
    + + + +
    +
    +
    +
    + ) +} diff --git a/next/src/components/OutfitCardLoading.tsx b/next/src/components/OutfitCardLoading.tsx index 3d1b401..2f7c5e7 100644 --- a/next/src/components/OutfitCardLoading.tsx +++ b/next/src/components/OutfitCardLoading.tsx @@ -1,16 +1,20 @@ import { Card, CardContent } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" -import { ItemListLoading } from "./ItemListLoading" +import { ItemLoading } from "./ItemLoading" export function OutfitCardLoading() { return (
    - - + + +
    +
    + {[...Array(3)].map((_, index) => ( + + ))}
    -
    ) diff --git a/next/src/components/OutfitList.tsx b/next/src/components/OutfitList.tsx index e8b9e1e..9884156 100644 --- a/next/src/components/OutfitList.tsx +++ b/next/src/components/OutfitList.tsx @@ -1,12 +1,12 @@ 'use client' -import { useCallback, useRef } from 'react' +import { useCallback, useRef, useState } from 'react' import { Card, CardContent } from '@/components/ui/card' -import { Calendar, MapPin, Trash2 } from 'lucide-react' +import { Calendar, MapPin, Trash2, Loader2 } from 'lucide-react' import { ItemList } from '@/components/ItemList' import OutfitListLoading from './OutfitListLoading' -import Rating from '@/components/ui/rating' import { Tag } from "@/components/ui/tag" +import Rating from '@/components/ui/rating' import { useOutfits, useItems, useTags } from '@/lib/client' import { ContextMenu, @@ -17,9 +17,12 @@ import { export default function OutfitList() { const { outfits, isLoading: isLoadingOutfits, isLoadingMore, isError: isErrorOutfits, isReachingEnd, loadMore, deleteOutfit } = useOutfits() - const { items, isLoading: isLoadingItems, isError: isErrorItems } = useItems() + const { isLoading: isLoadingItems, isError: isErrorItems, updateItemStatus, mutate: mutateItems } = useItems() const { tags, isLoading: isLoadingTags, isError: isErrorTags } = useTags() const observer = useRef(null) + const [deletingOutfitId, setDeletingOutfitId] = useState(null) + const [statusChangingItemId, setStatusChangingItemId] = useState(null) + const [changingToStatus, setChangingToStatus] = useState(null) const lastOutfitElementRef = useCallback((node: HTMLDivElement | null) => { if (isLoadingOutfits || isLoadingItems || isLoadingTags || isLoadingMore) return @@ -46,10 +49,29 @@ export default function OutfitList() { } const handleDelete = async (outfitId: string) => { + setDeletingOutfitId(outfitId) try { await deleteOutfit(outfitId) } catch (error) { console.error('Error deleting outfit:', error) + } finally { + setDeletingOutfitId(null) + } + } + + const handleItemStatusChange = async (itemId: string, newStatus: any) => { + setStatusChangingItemId(itemId) + setChangingToStatus(newStatus) + try { + const success = await updateItemStatus(itemId, newStatus) + if (success) { + mutateItems() + } + } catch (error) { + console.error('Error updating item status:', error) + } finally { + setStatusChangingItemId(null) + setChangingToStatus(null) } } @@ -78,40 +100,57 @@ export default function OutfitList() { -
    - - - {outfit.wearDate ? formatDate(outfit.wearDate) : 'Never'} - {outfit.locationLatitude && outfit.locationLongitude && ( - - )} -
    - {outfit.tagsToOutfits.map((tagToOutfit) => { - const tag = tags?.find(t => t.id === tagToOutfit.tagId) - if (!tag) return null - return ( - + +
    + {Array.isArray(outfit.tagsToOutfits) && outfit.tagsToOutfits.length > 0 ? ( + outfit.tagsToOutfits.map((tagToOutfit) => { + const tag = tags?.find(t => t.id === tagToOutfit.tagId) + return tag ? ( + + ) : null + }) + ) : ( - ) - })} + )}
    + + {outfit.locationLatitude && outfit.locationLongitude ? ( + + ) : null}
    - + {outfit.wearDate && formatDate(outfit.wearDate)}
    - + handleDelete(outfit.id)} + className={`text-destructive focus:text-destructive ${deletingOutfitId === outfit.id ? 'opacity-50 cursor-not-allowed' : ''}`} + onClick={deletingOutfitId === outfit.id ? undefined : () => handleDelete(outfit.id)} + disabled={deletingOutfitId === outfit.id} > - + {deletingOutfitId === outfit.id ? ( + + ) : ( + + )} Delete Outfit diff --git a/next/src/components/OutfitSuggestions.tsx b/next/src/components/OutfitSuggestions.tsx index c6c4aad..83e1521 100644 --- a/next/src/components/OutfitSuggestions.tsx +++ b/next/src/components/OutfitSuggestions.tsx @@ -6,11 +6,9 @@ import { SuggestionScoreBar } from '@/components/SuggestionScoreBar' import { ItemList } from '@/components/ItemList' import OutfitSuggestionsLoading from './OutfitSuggestionsLoading' import Rating from '@/components/ui/rating' -import { useSuggestedOutfits, useTags } from '@/lib/client' +import { useSuggestedOutfits, useTags, useItems } from '@/lib/client' import { Tag } from '@/components/ui/tag' -import { ScrollArea } from '@/components/ui/scroll-area' -import { X } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { MapPin } from 'lucide-react' export default function OutfitSuggestions() { const [selectedTagId, setSelectedTagId] = useState(undefined) @@ -24,6 +22,9 @@ export default function OutfitSuggestions() { loadMore } = useSuggestedOutfits(selectedTagId) const { tags, isLoading: isLoadingTags } = useTags() + const { updateItemStatus, mutate: mutateItems } = useItems() + const [statusChangingItemId, setStatusChangingItemId] = useState(null) + const [changingToStatus, setChangingToStatus] = useState(null) const observer = useRef(null) @@ -48,8 +49,20 @@ export default function OutfitSuggestions() { } } - const clearTagFilter = () => { - setSelectedTagId(undefined) + const handleItemStatusChange = async (itemId: string, newStatus: any) => { + setStatusChangingItemId(itemId) + setChangingToStatus(newStatus) + try { + const success = await updateItemStatus(itemId, newStatus) + if (success) { + mutateItems() + } + } catch (error) { + console.error('Error updating item status:', error) + } finally { + setStatusChangingItemId(null) + setChangingToStatus(null) + } } if ((isLoading || isLoadingTags) && suggestions.length === 0) { @@ -107,24 +120,34 @@ export default function OutfitSuggestions() { > -
    - -
    - {suggestion.tagsToOutfits.map((tagToOutfit) => { - const tag = tags?.find(t => t.id === tagToOutfit.tagId) - if (!tag) return null - return ( - - ) - })} +
    + +
    + {Array.isArray(suggestion.tagsToOutfits) && suggestion.tagsToOutfits.length > 0 ? ( + suggestion.tagsToOutfits.map((tagToOutfit) => { + const tag = tags?.find(t => t.id === tagToOutfit.tagId) + return tag ? ( + + ) : null + }) + ) : ( + )}
    + + {suggestion.locationLatitude && suggestion.locationLongitude ? ( + + ) : null}
    -
    @@ -143,6 +166,10 @@ export default function OutfitSuggestions() {

    Last Worn: {suggestion.wearDate ? `${suggestion.scoringDetails.rawData.daysSinceWorn} days ago` : 'Never'}

    diff --git a/next/src/components/SelectableItem.tsx b/next/src/components/SelectableItem.tsx new file mode 100644 index 0000000..0b335bc --- /dev/null +++ b/next/src/components/SelectableItem.tsx @@ -0,0 +1,256 @@ +'use client' + +import { Layers, Shirt, Footprints, Crown, Circle } from 'lucide-react' +import { PiPantsFill } from 'react-icons/pi' +import { DateTime } from "luxon" +import { ItemsResponse } from '@/lib/client' +import { cn } from '@/lib/utils' +import { ITEM_STATUS, ITEM_STATUS_LABELS, ItemStatus } from '@/lib/types' +import { useState, useRef, useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { ItemActions } from './ItemActions' + +export const itemTypeIcons = { + layer: Layers, + top: Shirt, + bottom: PiPantsFill, + footwear: Footprints, + accessory: Crown, +} as const + +type ItemType = keyof typeof itemTypeIcons + +interface SelectableItemProps { + item: ItemsResponse['items'][number] + itemType?: string + isCoreItem?: boolean + showLastWornAt?: boolean + freshness?: number + // Selection props + enableSelection?: boolean + isSelected?: boolean + hasAnySelection?: boolean + onToggleSelection?: (itemId: string) => void + // Status change props + isStatusChanging?: boolean + changingToStatus?: ItemStatus + onStatusChange?: (itemId: string, status: ItemStatus) => Promise + // Action handlers + onView?: (item: ItemsResponse['items'][number]) => void + onEdit?: (item: ItemsResponse['items'][number]) => void + onDelete?: (item: ItemsResponse['items'][number]) => void + // Display options + showThreeDotsMenu?: boolean + className?: string +} + +/** + * Core item component with built-in selection support and clean action handling. + * Replaces the complex Item component with a cleaner, more focused implementation. + */ +export function SelectableItem({ + item, + itemType, + isCoreItem = false, + showLastWornAt = false, + freshness, + enableSelection = false, + isSelected = false, + hasAnySelection = false, + onToggleSelection, + isStatusChanging = false, + changingToStatus, + onStatusChange, + onView, + onEdit, + onDelete, + showThreeDotsMenu = false, + className +}: SelectableItemProps) { + const Icon = itemTypeIcons[itemType as ItemType] || Layers + const freshnessDisplay = freshness !== undefined ? `${Math.round(freshness * 100)}%` : null + const [isHovered, setIsHovered] = useState(false) + const [isSwiping, setIsSwiping] = useState(false) + + // Show selection circle when: hovering (desktop) OR this item is selected OR any item is selected + const showSelectionCircle = enableSelection && (isHovered || isSelected || hasAnySelection) + + // Swipe detection for mobile + const touchStartX = useRef(0) + const touchStartY = useRef(0) + const touchStartTime = useRef(0) + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + if (!enableSelection) return + touchStartX.current = e.touches[0].clientX + touchStartY.current = e.touches[0].clientY + touchStartTime.current = Date.now() + }, [enableSelection]) + + const handleTouchEnd = useCallback((e: React.TouchEvent) => { + if (!enableSelection || !onToggleSelection || e.changedTouches.length === 0) return + + const touchEndX = e.changedTouches[0].clientX + const touchEndY = e.changedTouches[0].clientY + const touchEndTime = Date.now() + + const deltaX = touchStartX.current - touchEndX + const deltaY = Math.abs(touchStartY.current - touchEndY) + const deltaTime = touchEndTime - touchStartTime.current + + // Swipe left detection: minimum 50px horizontal, max 100px vertical, within 500ms + if (deltaX > 50 && deltaY < 100 && deltaTime < 500) { + e.preventDefault() + setIsSwiping(true) + onToggleSelection(item.id) + + // Reset swipe animation after completion + setTimeout(() => { + setIsSwiping(false) + }, 300) + } + }, [enableSelection, onToggleSelection, item.id]) + + const handleSelectionClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + if (onToggleSelection) { + onToggleSelection(item.id) + } + }, [onToggleSelection, item.id]) + + const handleItemClick = useCallback((e: React.MouseEvent) => { + // Only toggle selection if any items are already selected + if (hasAnySelection && onToggleSelection) { + e.stopPropagation() + onToggleSelection(item.id) + } + }, [hasAnySelection, onToggleSelection, item.id]) + + const itemContent = ( +
    setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onClick={handleItemClick} + > +
    + +
    + +
    +
    +

    + {item.name} + {isCoreItem && } +

    +

    + {item.status !== ITEM_STATUS.AVAILABLE && ( + <> + {ITEM_STATUS_LABELS[item.status as keyof typeof ITEM_STATUS_LABELS]} + + + )} + + {item.brand ? item.brand : Unbranded} + + {showLastWornAt && ( + <> + + {item.lastWornAt ? ( + <>Worn {DateTime.fromISO(item.lastWornAt).toRelativeCalendar({ locale: "en-US", unit: "days" })} + ) : ( + Never worn! + )} + + )} + {freshnessDisplay && ( + <> + + F: {freshnessDisplay} + + )} +

    +
    +
    + + {/* Three dots menu */} + {showThreeDotsMenu && ( +
    + +
    + )} + + {/* Selection circle */} + {showSelectionCircle && ( +
    + +
    + )} +
    + ) + + // If we have actions and not showing three dots, wrap with context menu + const hasActions = onView || onEdit || onDelete || onStatusChange + if (hasActions && !showThreeDotsMenu) { + return ( + +
    + {itemContent} +
    +
    + ) + } + + return
    {itemContent}
    +} diff --git a/next/src/components/SelectableItemList.tsx b/next/src/components/SelectableItemList.tsx new file mode 100644 index 0000000..9eb7243 --- /dev/null +++ b/next/src/components/SelectableItemList.tsx @@ -0,0 +1,108 @@ +'use client' + +import React from 'react' +import { SelectableItem } from './SelectableItem' +import { SelectionToolbar } from './SelectionToolbar' +import { useItemSelection } from '@/lib/hooks/useItemSelection' +import { ItemsResponse } from '@/lib/client' +import { ItemStatus } from '@/lib/types' + +interface SelectableItemListProps { + items: (ItemsResponse['items'][number] & { + itemType?: string + freshness?: number + })[] + coreItems?: string[] + showLastWornAt?: boolean + className?: string + enableSelection?: boolean + showToolbar?: boolean + // Item action handlers + onItemView?: (item: ItemsResponse['items'][number]) => void + onItemEdit?: (item: ItemsResponse['items'][number]) => void + onItemDelete?: (item: ItemsResponse['items'][number]) => void + // Custom render function for special cases + renderItem?: (item: ItemsResponse['items'][number] & { itemType?: string; freshness?: number }, index: number) => React.ReactNode +} + +/** + * High-level component that abstracts all selection logic and provides a clean interface + * for displaying lists of items with optional multi-selection capabilities. + */ +export function SelectableItemList({ + items, + coreItems = [], + showLastWornAt = false, + className = "space-y-1", + enableSelection = false, + showToolbar = true, + onItemView, + onItemEdit, + onItemDelete, + renderItem +}: SelectableItemListProps) { + const { + selectedItemIds, + isBatchUpdating, + statusChangingItemId, + changingToStatus, + handleToggleSelection, + handleClearSelection, + handleBatchStatusChange, + handleStatusChange, + } = useItemSelection() + + const selectedItems = items.filter(item => selectedItemIds.has(item.id)) + + const defaultRenderItem = (item: ItemsResponse['items'][number] & { itemType?: string; freshness?: number }, index: number) => ( +
  • + 0} + onToggleSelection={handleToggleSelection} + // Status change props + isStatusChanging={statusChangingItemId === item.id} + changingToStatus={changingToStatus || undefined} + onStatusChange={handleStatusChange} + // Action handlers + onView={onItemView} + onEdit={onItemEdit} + onDelete={onItemDelete} + /> +
  • + ) + + return ( + <> +
      + {items.map((item, index) => + renderItem ? renderItem(item, index) : defaultRenderItem(item, index) + )} +
    + + {/* Selection toolbar */} + {enableSelection && showToolbar && selectedItems.length > 0 && ( + {}} + onSearchClick={() => {}} + onSearchKeyDown={() => {}} + searchAddMode={false} + onSearchNewItem={() => {}} + /> + )} + + ) +} diff --git a/next/src/components/SelectionToolbar.tsx b/next/src/components/SelectionToolbar.tsx new file mode 100644 index 0000000..bdeb966 --- /dev/null +++ b/next/src/components/SelectionToolbar.tsx @@ -0,0 +1,174 @@ +'use client' + +import { Archive, Ban, ArchiveRestore, MoreHorizontal, X, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { ITEM_STATUS, ItemStatus } from '@/lib/types' +import { ItemsResponse } from '@/lib/client' +import { useState, useEffect } from 'react' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { ItemInlineSearch } from './ItemInlineSearch' + +interface SelectionToolbarProps { + selectedItems: ItemsResponse['items'][number][] + onBatchStatusChange: (status: ItemStatus) => Promise + onClearSelection: () => void + isUpdating: boolean + changingToStatus?: ItemStatus | null + // Search props + searchValue: string + onSearchChange: (e: React.ChangeEvent) => void + onSearchClick: () => void + onSearchKeyDown: (e: React.KeyboardEvent) => void + searchAddMode: boolean + onSearchNewItem: (itemId: string, itemType: string) => void +} + +interface StatusAction { + status: ItemStatus + label: string + icon: typeof Archive +} + +const statusActions: Record = { + [ITEM_STATUS.AVAILABLE]: { + status: ITEM_STATUS.AVAILABLE, + label: 'Make Available', + icon: ArchiveRestore, + }, + [ITEM_STATUS.WITHHELD]: { + status: ITEM_STATUS.WITHHELD, + label: 'Withhold', + icon: Ban, + }, + [ITEM_STATUS.RETIRED]: { + status: ITEM_STATUS.RETIRED, + label: 'Retire', + icon: Archive, + }, +} + +/** + * Gets all available actions for the selected items. + * All actions are now moved to the overflow menu. + */ +function getAllActionsForSelection(selectedItems: ItemsResponse['items'][number][]) { + const statuses = selectedItems.map(item => item.status) + const uniqueStatuses = [...new Set(statuses)] + + // If all items have the same status, show the two possible transitions + if (uniqueStatuses.length === 1) { + const currentStatus = uniqueStatuses[0] as ItemStatus + + switch (currentStatus) { + case ITEM_STATUS.AVAILABLE: + return [statusActions[ITEM_STATUS.WITHHELD], statusActions[ITEM_STATUS.RETIRED]] + case ITEM_STATUS.WITHHELD: + return [statusActions[ITEM_STATUS.AVAILABLE], statusActions[ITEM_STATUS.RETIRED]] + case ITEM_STATUS.RETIRED: + return [statusActions[ITEM_STATUS.AVAILABLE], statusActions[ITEM_STATUS.WITHHELD]] + } + } + + // Mixed statuses: show all possible actions + return [statusActions[ITEM_STATUS.AVAILABLE], statusActions[ITEM_STATUS.WITHHELD], statusActions[ITEM_STATUS.RETIRED]] +} + +export function SelectionToolbar({ + selectedItems, + onBatchStatusChange, + onClearSelection, + isUpdating, + changingToStatus, + searchValue, + onSearchChange, + onSearchClick, + onSearchKeyDown, + searchAddMode, + onSearchNewItem +}: SelectionToolbarProps) { + const allActions = getAllActionsForSelection(selectedItems) + const selectedCount = selectedItems.length + const [popoverOpen, setPopoverOpen] = useState(false) + + const handleClearSelection = () => { + onClearSelection() + } + + return ( +
    +
    + {/* Search input - takes full width on mobile, constrained on larger screens */} +
    + +
    + + {/* Actions overflow menu - only show when items are selected */} + {selectedCount > 0 && ( + + + + + +
    + {allActions.map(({ status, label, icon: Icon }) => { + const isThisActionLoading = isUpdating && changingToStatus === status + return ( +
    { + setPopoverOpen(false) + onBatchStatusChange(status) + }} + > + {isThisActionLoading ? ( + + ) : ( + + )} + {label} +
    + ) + })} +
    +
    +
    + )} + + + {/* Show X icon when items are selected */} + {selectedCount > 0 && ( + + )} +
    +
    + ) +} diff --git a/next/src/components/ui/avatar.tsx b/next/src/components/ui/avatar.tsx deleted file mode 100644 index 51e507b..0000000 --- a/next/src/components/ui/avatar.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client" - -import * as React from "react" -import * as AvatarPrimitive from "@radix-ui/react-avatar" - -import { cn } from "@/lib/utils" - -const Avatar = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -Avatar.displayName = AvatarPrimitive.Root.displayName - -const AvatarImage = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AvatarImage.displayName = AvatarPrimitive.Image.displayName - -const AvatarFallback = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName - -export { Avatar, AvatarImage, AvatarFallback } diff --git a/next/src/components/ui/badge.tsx b/next/src/components/ui/badge.tsx deleted file mode 100644 index e87d62b..0000000 --- a/next/src/components/ui/badge.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const badgeVariants = cva( - "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", - secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: - "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", - outline: "text-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} - -function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
    - ) -} - -export { Badge, badgeVariants } diff --git a/next/src/components/ui/command.tsx b/next/src/components/ui/command.tsx deleted file mode 100644 index 2cecd91..0000000 --- a/next/src/components/ui/command.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"use client" - -import * as React from "react" -import { type DialogProps } from "@radix-ui/react-dialog" -import { Command as CommandPrimitive } from "cmdk" -import { Search } from "lucide-react" - -import { cn } from "@/lib/utils" -import { Dialog, DialogContent } from "@/components/ui/dialog" - -const Command = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -Command.displayName = CommandPrimitive.displayName - -const CommandDialog = ({ children, ...props }: DialogProps) => { - return ( - - - - {children} - - - - ) -} - -const CommandInput = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( -
    - - -
    -)) - -CommandInput.displayName = CommandPrimitive.Input.displayName - -const CommandList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) - -CommandList.displayName = CommandPrimitive.List.displayName - -const CommandEmpty = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->((props, ref) => ( - -)) - -CommandEmpty.displayName = CommandPrimitive.Empty.displayName - -const CommandGroup = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) - -CommandGroup.displayName = CommandPrimitive.Group.displayName - -const CommandSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -CommandSeparator.displayName = CommandPrimitive.Separator.displayName - -const CommandItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) - -CommandItem.displayName = CommandPrimitive.Item.displayName - -const CommandShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ) -} -CommandShortcut.displayName = "CommandShortcut" - -export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, -} diff --git a/next/src/components/ui/input.tsx b/next/src/components/ui/input.tsx deleted file mode 100644 index f3c9b1e..0000000 --- a/next/src/components/ui/input.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => { - return ( - - ) - } -) -Input.displayName = "Input" - -export { Input } diff --git a/next/src/components/ui/label.tsx b/next/src/components/ui/label.tsx deleted file mode 100644 index 5341821..0000000 --- a/next/src/components/ui/label.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client" - -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const labelVariants = cva( - "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" -) - -const Label = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps ->(({ className, ...props }, ref) => ( - -)) -Label.displayName = LabelPrimitive.Root.displayName - -export { Label } diff --git a/next/src/components/ui/progress.tsx b/next/src/components/ui/progress.tsx deleted file mode 100644 index 4fc3b47..0000000 --- a/next/src/components/ui/progress.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client" - -import * as React from "react" -import * as ProgressPrimitive from "@radix-ui/react-progress" - -import { cn } from "@/lib/utils" - -const Progress = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, value, ...props }, ref) => ( - - - -)) -Progress.displayName = ProgressPrimitive.Root.displayName - -export { Progress } diff --git a/next/src/components/ui/rating.tsx b/next/src/components/ui/rating.tsx index 51accf6..6fe3a99 100644 --- a/next/src/components/ui/rating.tsx +++ b/next/src/components/ui/rating.tsx @@ -7,7 +7,7 @@ interface RatingProps { export default function Rating({ rating }: RatingProps) { return ( -
    +
    diff --git a/next/src/components/ui/tabs.tsx b/next/src/components/ui/tabs.tsx deleted file mode 100644 index 0f4caeb..0000000 --- a/next/src/components/ui/tabs.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client" - -import * as React from "react" -import * as TabsPrimitive from "@radix-ui/react-tabs" - -import { cn } from "@/lib/utils" - -const Tabs = TabsPrimitive.Root - -const TabsList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -TabsList.displayName = TabsPrimitive.List.displayName - -const TabsTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -TabsTrigger.displayName = TabsPrimitive.Trigger.displayName - -const TabsContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -TabsContent.displayName = TabsPrimitive.Content.displayName - -export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/next/src/components/ui/tag.tsx b/next/src/components/ui/tag.tsx index 6d797c4..ff47b6c 100644 --- a/next/src/components/ui/tag.tsx +++ b/next/src/components/ui/tag.tsx @@ -1,15 +1,17 @@ import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" +import { ReactNode } from "react" interface TagProps { name: string hexColor?: string + icon?: ReactNode selected?: boolean compact?: boolean onClick?: () => void } -export function Tag({ name, hexColor, selected = false, compact = true, onClick }: TagProps) { +export function Tag({ name, hexColor, icon, selected = false, compact = true, onClick }: TagProps) { return (