feat(34-02): i18n collection and item components
- CollectionView: t() for empty state, stats labels, filter text - ItemCard: t() for tooltip title attributes - ItemForm: t() for all form labels, placeholders, error messages, buttons - CategoryPicker: t() for search placeholder, create button, no results - CategoryFilterDropdown: t() for all categories label, search placeholder - CategoryHeader: t() for save/cancel buttons, item count - WeightSummaryCard: t() for title, legend labels, view mode toggle - ItemPicker: t() for panel title, empty state, action buttons - ManualEntryForm: t() for all form labels, error messages, submit button - LinkToGlobalItem: t() for all UI chrome strings - ProfileSection: t() for all form labels, messages, buttons - collection.json: added new keys for categoryPicker, categoryFilter, weightSummary, itemPicker, categoryHeader, linkToGlobal, manualEntry, profileSection, itemCard
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
|
||||||
interface CategoryFilterDropdownProps {
|
interface CategoryFilterDropdownProps {
|
||||||
@@ -12,6 +13,7 @@ export function CategoryFilterDropdown({
|
|||||||
onChange,
|
onChange,
|
||||||
categories,
|
categories,
|
||||||
}: CategoryFilterDropdownProps) {
|
}: CategoryFilterDropdownProps) {
|
||||||
|
const { t } = useTranslation("collection");
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -81,7 +83,7 @@ export function CategoryFilterDropdown({
|
|||||||
<span className="text-gray-900">{selectedCategory.name}</span>
|
<span className="text-gray-900">{selectedCategory.name}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-600">All categories</span>
|
<span className="text-gray-600">{t("categoryFilter.allCategories")}</span>
|
||||||
)}
|
)}
|
||||||
{selectedCategory ? (
|
{selectedCategory ? (
|
||||||
<button
|
<button
|
||||||
@@ -131,7 +133,7 @@ export function CategoryFilterDropdown({
|
|||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search categories..."
|
placeholder={t("categoryFilter.searchPlaceholder")}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
className="w-full px-2.5 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
className="w-full px-2.5 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
@@ -153,7 +155,7 @@ export function CategoryFilterDropdown({
|
|||||||
: "text-gray-700"
|
: "text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
All categories
|
{t("categoryFilter.allCategories")}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
@@ -187,7 +189,7 @@ export function CategoryFilterDropdown({
|
|||||||
"all categories".includes(searchText.toLowerCase())
|
"all categories".includes(searchText.toLowerCase())
|
||||||
) && (
|
) && (
|
||||||
<li className="px-3 py-2 text-sm text-gray-400">
|
<li className="px-3 py-2 text-sm text-gray-400">
|
||||||
No categories found
|
{t("categoryFilter.noResults")}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
|
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
@@ -21,6 +22,7 @@ export function CategoryHeader({
|
|||||||
totalCost,
|
totalCost,
|
||||||
itemCount,
|
itemCount,
|
||||||
}: CategoryHeaderProps) {
|
}: CategoryHeaderProps) {
|
||||||
|
const { t } = useTranslation("collection");
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editName, setEditName] = useState(name);
|
const [editName, setEditName] = useState(name);
|
||||||
@@ -67,14 +69,14 @@ export function CategoryHeader({
|
|||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="text-sm text-gray-600 hover:text-gray-800 font-medium"
|
className="text-sm text-gray-600 hover:text-gray-800 font-medium"
|
||||||
>
|
>
|
||||||
Save
|
{t("categoryHeader.save")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="text-sm text-gray-400 hover:text-gray-600"
|
className="text-sm text-gray-400 hover:text-gray-600"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("categoryHeader.cancel")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -85,7 +87,7 @@ export function CategoryHeader({
|
|||||||
<LucideIcon name={icon} size={22} className="text-gray-500" />
|
<LucideIcon name={icon} size={22} className="text-gray-500" />
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
|
||||||
<span className="text-sm text-gray-400">
|
<span className="text-sm text-gray-400">
|
||||||
{itemCount} {itemCount === 1 ? "item" : "items"} · {weight(totalWeight)}{" "}
|
{t("categoryHeader.itemCount", { count: itemCount })} · {weight(totalWeight)}{" "}
|
||||||
· {price(totalCost)}
|
· {price(totalCost)}
|
||||||
</span>
|
</span>
|
||||||
{!isUncategorized && (
|
{!isUncategorized && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useCategories, useCreateCategory } from "../hooks/useCategories";
|
import { useCategories, useCreateCategory } from "../hooks/useCategories";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { IconPicker } from "./IconPicker";
|
import { IconPicker } from "./IconPicker";
|
||||||
@@ -9,6 +10,7 @@ interface CategoryPickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
||||||
|
const { t } = useTranslation("collection");
|
||||||
const { data: categories = [] } = useCategories();
|
const { data: categories = [] } = useCategories();
|
||||||
const createCategory = useCreateCategory();
|
const createCategory = useCreateCategory();
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
@@ -158,7 +160,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
|||||||
value={
|
value={
|
||||||
isOpen ? inputValue : selectedCategory ? selectedCategory.name : ""
|
isOpen ? inputValue : selectedCategory ? selectedCategory.name : ""
|
||||||
}
|
}
|
||||||
placeholder="Search or create category..."
|
placeholder={t("categoryPicker.searchOrCreate")}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setInputValue(e.target.value);
|
setInputValue(e.target.value);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
@@ -233,14 +235,14 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
|
|||||||
disabled={createCategory.isPending}
|
disabled={createCategory.isPending}
|
||||||
className="text-xs font-medium text-gray-600 hover:text-gray-800 disabled:opacity-50"
|
className="text-xs font-medium text-gray-600 hover:text-gray-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{createCategory.isPending ? "..." : "Create"}
|
{createCategory.isPending ? "..." : t("categoryPicker.create")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
{filtered.length === 0 && !showCreateOption && (
|
{filtered.length === 0 && !showCreateOption && (
|
||||||
<li className="px-3 py-2 text-sm text-gray-400">
|
<li className="px-3 py-2 text-sm text-gray-400">
|
||||||
No categories found
|
{t("categoryPicker.noCategories")}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useCategories } from "../hooks/useCategories";
|
import { useCategories } from "../hooks/useCategories";
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useItems } from "../hooks/useItems";
|
import { useItems } from "../hooks/useItems";
|
||||||
@@ -10,6 +11,7 @@ import { CategoryHeader } from "./CategoryHeader";
|
|||||||
import { ItemCard } from "./ItemCard";
|
import { ItemCard } from "./ItemCard";
|
||||||
|
|
||||||
export function CollectionView() {
|
export function CollectionView() {
|
||||||
|
const { t } = useTranslation(["collection", "common"]);
|
||||||
const { data: items, isLoading: itemsLoading } = useItems();
|
const { data: items, isLoading: itemsLoading } = useItems();
|
||||||
const { data: totals } = useTotals();
|
const { data: totals } = useTotals();
|
||||||
const { data: categories } = useCategories();
|
const { data: categories } = useCategories();
|
||||||
@@ -58,11 +60,10 @@ export function CollectionView() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
Your collection is empty
|
{t("collection:empty.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
Start cataloging your gear by adding your first item. Track weight,
|
{t("collection:empty.description")}
|
||||||
price, and organize by category.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -83,7 +84,7 @@ export function CollectionView() {
|
|||||||
d="M12 4v16m8-8H4"
|
d="M12 4v16m8-8H4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Add your first item
|
{t("collection:empty.addFirst")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,14 +137,14 @@ export function CollectionView() {
|
|||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<LucideIcon name="layers" size={14} className="text-gray-400" />
|
<LucideIcon name="layers" size={14} className="text-gray-400" />
|
||||||
<span className="text-xs text-gray-500">Items</span>
|
<span className="text-xs text-gray-500">{t("common:stats.items")}</span>
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
{totals.global.itemCount}
|
{totals.global.itemCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<LucideIcon name="weight" size={14} className="text-gray-400" />
|
<LucideIcon name="weight" size={14} className="text-gray-400" />
|
||||||
<span className="text-xs text-gray-500">Total Weight</span>
|
<span className="text-xs text-gray-500">{t("common:stats.totalWeight")}</span>
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
{weight(totals.global.totalWeight)}
|
{weight(totals.global.totalWeight)}
|
||||||
</span>
|
</span>
|
||||||
@@ -154,7 +155,7 @@ export function CollectionView() {
|
|||||||
size={14}
|
size={14}
|
||||||
className="text-gray-400"
|
className="text-gray-400"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-500">Total Spent</span>
|
<span className="text-xs text-gray-500">{t("common:stats.totalSpent")}</span>
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
{price(totals.global.totalCost)}
|
{price(totals.global.totalCost)}
|
||||||
</span>
|
</span>
|
||||||
@@ -169,7 +170,7 @@ export function CollectionView() {
|
|||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search items..."
|
placeholder={t("common:filter.searchItems")}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
@@ -204,7 +205,7 @@ export function CollectionView() {
|
|||||||
</div>
|
</div>
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
Showing {filteredItems.length} of {items.length} items
|
{t("common:filter.showing", { filtered: filteredItems.length, total: items.length })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +214,7 @@ export function CollectionView() {
|
|||||||
{hasActiveFilters ? (
|
{hasActiveFilters ? (
|
||||||
filteredItems.length === 0 ? (
|
filteredItems.length === 0 ? (
|
||||||
<div className="py-12 text-center">
|
<div className="py-12 text-center">
|
||||||
<p className="text-sm text-gray-500">No items match your search</p>
|
<p className="text-sm text-gray-500">{t("common:empty.noItems")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useDuplicateItem } from "../hooks/useItems";
|
import { useDuplicateItem } from "../hooks/useItems";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
@@ -51,6 +52,7 @@ export function ItemCard({
|
|||||||
linkTo,
|
linkTo,
|
||||||
priceCurrency,
|
priceCurrency,
|
||||||
}: ItemCardProps) {
|
}: ItemCardProps) {
|
||||||
|
const { t } = useTranslation("collection");
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||||
@@ -102,7 +104,7 @@ export function ItemCard({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`absolute top-2 ${productUrl ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
|
className={`absolute top-2 ${productUrl ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-blue-100 hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
|
||||||
title="Duplicate item"
|
title={t("itemCard.duplicateItem")}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-3.5 h-3.5"
|
className="w-3.5 h-3.5"
|
||||||
@@ -134,7 +136,7 @@ export function ItemCard({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`absolute top-2 ${onRemove ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-gray-200 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
|
className={`absolute top-2 ${onRemove ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-gray-200 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
|
||||||
title="Open product link"
|
title={t("itemCard.openProductLink")}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-3.5 h-3.5"
|
className="w-3.5 h-3.5"
|
||||||
@@ -166,7 +168,7 @@ export function ItemCard({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
|
className="absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
|
||||||
title="Remove from setup"
|
title={t("itemCard.removeFromSetup")}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-3.5 h-3.5"
|
className="w-3.5 h-3.5"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useCurrency } from "../hooks/useCurrency";
|
||||||
import { useCreateItem, useItems, useUpdateItem } from "../hooks/useItems";
|
import { useCreateItem, useItems, useUpdateItem } from "../hooks/useItems";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
@@ -34,6 +35,7 @@ const INITIAL_FORM: FormData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
||||||
|
const { t } = useTranslation(["collection", "common"]);
|
||||||
const { data: items } = useItems();
|
const { data: items } = useItems();
|
||||||
const { currency } = useCurrency();
|
const { currency } = useCurrency();
|
||||||
const createItem = useCreateItem();
|
const createItem = useCreateItem();
|
||||||
@@ -68,26 +70,26 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
function validate(): boolean {
|
function validate(): boolean {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
if (!form.name.trim()) {
|
if (!form.name.trim()) {
|
||||||
newErrors.name = "Name is required";
|
newErrors.name = t("common:errors.nameRequired");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
form.weightGrams &&
|
form.weightGrams &&
|
||||||
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
|
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
|
||||||
) {
|
) {
|
||||||
newErrors.weightGrams = "Must be a positive number";
|
newErrors.weightGrams = t("common:errors.positiveNumber");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
form.priceDollars &&
|
form.priceDollars &&
|
||||||
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
|
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
|
||||||
) {
|
) {
|
||||||
newErrors.priceDollars = "Must be a positive number";
|
newErrors.priceDollars = t("common:errors.positiveNumber");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
form.productUrl &&
|
form.productUrl &&
|
||||||
form.productUrl.trim() !== "" &&
|
form.productUrl.trim() !== "" &&
|
||||||
!form.productUrl.match(/^https?:\/\//)
|
!form.productUrl.match(/^https?:\/\//)
|
||||||
) {
|
) {
|
||||||
newErrors.productUrl = "Must be a valid URL (https://...)";
|
newErrors.productUrl = t("common:errors.validUrl");
|
||||||
}
|
}
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
@@ -148,7 +150,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
htmlFor="item-name"
|
htmlFor="item-name"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
Name *
|
{t("collection:form.nameRequired")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="item-name"
|
id="item-name"
|
||||||
@@ -156,7 +158,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
placeholder="e.g. Osprey Talon 22"
|
placeholder={t("collection:form.namePlaceholder")}
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
||||||
@@ -169,7 +171,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
htmlFor="item-weight"
|
htmlFor="item-weight"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
Weight (g)
|
{t("collection:form.weight")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="item-weight"
|
id="item-weight"
|
||||||
@@ -181,7 +183,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
setForm((f) => ({ ...f, weightGrams: e.target.value }))
|
setForm((f) => ({ ...f, weightGrams: e.target.value }))
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
placeholder="e.g. 680"
|
placeholder={t("collection:form.weightPlaceholder")}
|
||||||
/>
|
/>
|
||||||
{errors.weightGrams && (
|
{errors.weightGrams && (
|
||||||
<p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p>
|
<p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p>
|
||||||
@@ -194,7 +196,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
htmlFor="item-price"
|
htmlFor="item-price"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
{`Price (${currency})`}
|
{`${t("collection:form.price")} (${currency})`}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="item-price"
|
id="item-price"
|
||||||
@@ -206,7 +208,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
setForm((f) => ({ ...f, priceDollars: e.target.value }))
|
setForm((f) => ({ ...f, priceDollars: e.target.value }))
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
placeholder="e.g. 129.99"
|
placeholder={t("collection:form.pricePlaceholder")}
|
||||||
/>
|
/>
|
||||||
{errors.priceDollars && (
|
{errors.priceDollars && (
|
||||||
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
|
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
|
||||||
@@ -219,7 +221,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
htmlFor="item-quantity"
|
htmlFor="item-quantity"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
Quantity
|
{t("collection:form.quantity")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="item-quantity"
|
id="item-quantity"
|
||||||
@@ -240,7 +242,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
{/* Category */}
|
{/* Category */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Category
|
{t("collection:form.category")}
|
||||||
</label>
|
</label>
|
||||||
<CategoryPicker
|
<CategoryPicker
|
||||||
value={form.categoryId}
|
value={form.categoryId}
|
||||||
@@ -254,7 +256,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
htmlFor="item-notes"
|
htmlFor="item-notes"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
Notes
|
{t("collection:form.notes")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="item-notes"
|
id="item-notes"
|
||||||
@@ -262,7 +264,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
|
||||||
placeholder="Any additional notes..."
|
placeholder={t("collection:form.notesPlaceholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -272,7 +274,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
htmlFor="item-url"
|
htmlFor="item-url"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
Product Link
|
{t("collection:form.productLink")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="item-url"
|
id="item-url"
|
||||||
@@ -282,7 +284,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
setForm((f) => ({ ...f, productUrl: e.target.value }))
|
setForm((f) => ({ ...f, productUrl: e.target.value }))
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
placeholder="https://..."
|
placeholder={t("collection:form.urlPlaceholder")}
|
||||||
/>
|
/>
|
||||||
{errors.productUrl && (
|
{errors.productUrl && (
|
||||||
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
|
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
|
||||||
@@ -297,10 +299,10 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{isPending
|
{isPending
|
||||||
? "Saving..."
|
? t("common:actions.saving")
|
||||||
: mode === "add"
|
: mode === "add"
|
||||||
? "Add Item"
|
? t("common:actions.addItem")
|
||||||
: "Save Changes"}
|
: t("common:actions.saveChanges")}
|
||||||
</button>
|
</button>
|
||||||
{mode === "edit" && itemId != null && (
|
{mode === "edit" && itemId != null && (
|
||||||
<button
|
<button
|
||||||
@@ -308,7 +310,7 @@ export function ItemForm({ mode, itemId, onClose }: ItemFormProps) {
|
|||||||
onClick={() => openConfirmDelete(itemId)}
|
onClick={() => openConfirmDelete(itemId)}
|
||||||
className="py-2.5 px-4 text-red-600 hover:bg-red-50 text-sm font-medium rounded-lg transition-colors"
|
className="py-2.5 px-4 text-red-600 hover:bg-red-50 text-sm font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
{t("common:actions.delete")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useItems } from "../hooks/useItems";
|
import { useItems } from "../hooks/useItems";
|
||||||
import { useSyncSetupItems } from "../hooks/useSetups";
|
import { useSyncSetupItems } from "../hooks/useSetups";
|
||||||
@@ -18,6 +19,7 @@ export function ItemPicker({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
}: ItemPickerProps) {
|
}: ItemPickerProps) {
|
||||||
|
const { t } = useTranslation(["collection", "common"]);
|
||||||
const { data: items } = useItems();
|
const { data: items } = useItems();
|
||||||
const syncItems = useSyncSetupItems(setupId);
|
const syncItems = useSyncSetupItems(setupId);
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
@@ -74,13 +76,13 @@ export function ItemPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SlideOutPanel isOpen={isOpen} onClose={onClose} title="Select Items">
|
<SlideOutPanel isOpen={isOpen} onClose={onClose} title={t("collection:itemPicker.title")}>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 overflow-y-auto -mx-6 px-6">
|
<div className="flex-1 overflow-y-auto -mx-6 px-6">
|
||||||
{!items || items.length === 0 ? (
|
{!items || items.length === 0 ? (
|
||||||
<div className="py-8 text-center">
|
<div className="py-8 text-center">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
No items in your collection yet.
|
{t("collection:itemPicker.noItems")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -136,7 +138,7 @@ export function ItemPicker({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("common:actions.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -144,7 +146,7 @@ export function ItemPicker({
|
|||||||
disabled={syncItems.isPending}
|
disabled={syncItems.isPending}
|
||||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{syncItems.isPending ? "Saving..." : "Done"}
|
{syncItems.isPending ? t("common:actions.saving") : t("collection:itemPicker.done")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
useGlobalItem,
|
useGlobalItem,
|
||||||
useGlobalItems,
|
useGlobalItems,
|
||||||
@@ -17,6 +18,7 @@ export function LinkToGlobalItem({
|
|||||||
itemId,
|
itemId,
|
||||||
linkedGlobalItemId,
|
linkedGlobalItemId,
|
||||||
}: LinkToGlobalItemProps) {
|
}: LinkToGlobalItemProps) {
|
||||||
|
const { t } = useTranslation("collection");
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||||
@@ -85,7 +87,7 @@ export function LinkToGlobalItem({
|
|||||||
disabled={unlinkItem.isPending}
|
disabled={unlinkItem.isPending}
|
||||||
className="text-xs text-gray-400 hover:text-red-500 transition-colors shrink-0"
|
className="text-xs text-gray-400 hover:text-red-500 transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
{unlinkItem.isPending ? "..." : "Unlink"}
|
{unlinkItem.isPending ? "..." : t("linkToGlobal.unlink")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +115,7 @@ export function LinkToGlobalItem({
|
|||||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Link to catalog
|
{t("linkToGlobal.linkToCatalog")}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,7 +126,7 @@ export function LinkToGlobalItem({
|
|||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-medium text-gray-500">
|
<span className="text-xs font-medium text-gray-500">
|
||||||
Link to global catalog
|
{t("linkToGlobal.linkToGlobalCatalog")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -154,7 +156,7 @@ export function LinkToGlobalItem({
|
|||||||
type="text"
|
type="text"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
placeholder="Search by brand or model..."
|
placeholder={t("linkToGlobal.searchPlaceholder")}
|
||||||
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-200 focus:border-gray-300"
|
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-200 focus:border-gray-300"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -165,7 +167,7 @@ export function LinkToGlobalItem({
|
|||||||
<div className="border-t border-gray-100 max-h-48 overflow-y-auto">
|
<div className="border-t border-gray-100 max-h-48 overflow-y-auto">
|
||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
<div className="p-3 text-center">
|
<div className="p-3 text-center">
|
||||||
<span className="text-xs text-gray-400">Searching...</span>
|
<span className="text-xs text-gray-400">{t("linkToGlobal.searching")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : searchResults && searchResults.length > 0 ? (
|
) : searchResults && searchResults.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -195,7 +197,7 @@ export function LinkToGlobalItem({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-3 text-center">
|
<div className="p-3 text-center">
|
||||||
<span className="text-xs text-gray-400">No items found</span>
|
<span className="text-xs text-gray-400">{t("linkToGlobal.noItemsFound")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useCategories } from "../hooks/useCategories";
|
import { useCategories } from "../hooks/useCategories";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useCurrency } from "../hooks/useCurrency";
|
||||||
import { useCreateItem } from "../hooks/useItems";
|
import { useCreateItem } from "../hooks/useItems";
|
||||||
@@ -14,6 +15,7 @@ export function ManualEntryForm({
|
|||||||
initialName,
|
initialName,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: ManualEntryFormProps) {
|
}: ManualEntryFormProps) {
|
||||||
|
const { t } = useTranslation(["collection", "common"]);
|
||||||
const { data: categories } = useCategories();
|
const { data: categories } = useCategories();
|
||||||
const { currency } = useCurrency();
|
const { currency } = useCurrency();
|
||||||
const createItem = useCreateItem();
|
const createItem = useCreateItem();
|
||||||
@@ -39,11 +41,11 @@ export function ManualEntryForm({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError("Name is required");
|
setError(t("collection:manualEntry.nameRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (categoryId === null) {
|
if (categoryId === null) {
|
||||||
setError("Please select a category");
|
setError(t("collection:manualEntry.selectCategory"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ export function ManualEntryForm({
|
|||||||
onSuccess(item.name);
|
onSuccess(item.name);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setError(err instanceof Error ? err.message : "Failed to save");
|
setError(err instanceof Error ? err.message : t("collection:manualEntry.failedToSave"));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -92,14 +94,14 @@ export function ManualEntryForm({
|
|||||||
htmlFor="manual-name"
|
htmlFor="manual-name"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
Name <span className="text-red-500">*</span>
|
{t("collection:form.name")} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="manual-name"
|
id="manual-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Item name"
|
placeholder={t("collection:manualEntry.namePlaceholder")}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
@@ -108,7 +110,7 @@ export function ManualEntryForm({
|
|||||||
{/* Category */}
|
{/* Category */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Category
|
{t("collection:form.category")}
|
||||||
</label>
|
</label>
|
||||||
<CategoryPicker
|
<CategoryPicker
|
||||||
value={categoryId ?? 0}
|
value={categoryId ?? 0}
|
||||||
@@ -123,7 +125,7 @@ export function ManualEntryForm({
|
|||||||
htmlFor="manual-weight"
|
htmlFor="manual-weight"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
Weight (g)
|
{t("collection:manualEntry.weightLabel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="manual-weight"
|
id="manual-weight"
|
||||||
@@ -141,7 +143,7 @@ export function ManualEntryForm({
|
|||||||
htmlFor="manual-price"
|
htmlFor="manual-price"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
MSRP ($)
|
{t("collection:manualEntry.msrpLabel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="manual-price"
|
id="manual-price"
|
||||||
@@ -162,7 +164,7 @@ export function ManualEntryForm({
|
|||||||
htmlFor="manual-purchase-price"
|
htmlFor="manual-purchase-price"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
{`Purchase Price (${currency})`}
|
{`${t("collection:manualEntry.purchasePrice")} (${currency})`}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="manual-purchase-price"
|
id="manual-purchase-price"
|
||||||
@@ -182,14 +184,14 @@ export function ManualEntryForm({
|
|||||||
htmlFor="manual-product-url"
|
htmlFor="manual-product-url"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
Product Link
|
{t("collection:manualEntry.productLink")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="manual-product-url"
|
id="manual-product-url"
|
||||||
type="url"
|
type="url"
|
||||||
value={productUrl}
|
value={productUrl}
|
||||||
onChange={(e) => setProductUrl(e.target.value)}
|
onChange={(e) => setProductUrl(e.target.value)}
|
||||||
placeholder="https://..."
|
placeholder={t("collection:form.urlPlaceholder")}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,13 +202,13 @@ export function ManualEntryForm({
|
|||||||
htmlFor="manual-notes"
|
htmlFor="manual-notes"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
Notes
|
{t("collection:manualEntry.notesLabel")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="manual-notes"
|
id="manual-notes"
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
placeholder="Optional notes..."
|
placeholder={t("collection:manualEntry.optionalNotes")}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
|
||||||
/>
|
/>
|
||||||
@@ -221,7 +223,7 @@ export function ManualEntryForm({
|
|||||||
disabled={createItem.isPending || !name.trim() || categoryId === null}
|
disabled={createItem.isPending || !name.trim() || categoryId === null}
|
||||||
className="w-full px-4 py-2.5 text-sm font-medium text-white bg-gray-900 rounded-lg hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
className="w-full px-4 py-2.5 text-sm font-medium text-white bg-gray-900 rounded-lg hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{createItem.isPending ? "Saving..." : "Add to Collection"}
|
{createItem.isPending ? t("common:actions.saving") : t("collection:manualEntry.addToCollection")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { usePublicProfile, useUpdateProfile } from "../hooks/useProfile";
|
import { usePublicProfile, useUpdateProfile } from "../hooks/useProfile";
|
||||||
import { apiUpload } from "../lib/api";
|
import { apiUpload } from "../lib/api";
|
||||||
|
|
||||||
export function ProfileSection() {
|
export function ProfileSection() {
|
||||||
|
const { t } = useTranslation(["collection", "common"]);
|
||||||
const { data: auth } = useAuth();
|
const { data: auth } = useAuth();
|
||||||
const userId = auth?.user?.id ?? null;
|
const userId = auth?.user?.id ?? null;
|
||||||
const { data: profile } = usePublicProfile(userId);
|
const { data: profile } = usePublicProfile(userId);
|
||||||
@@ -40,7 +42,7 @@ export function ProfileSection() {
|
|||||||
bio: bio.trim() || undefined,
|
bio: bio.trim() || undefined,
|
||||||
});
|
});
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setMessage({ type: "success", text: "Profile updated" });
|
setMessage({ type: "success", text: t("profileSection.profileUpdated") });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage({ type: "error", text: (err as Error).message });
|
setMessage({ type: "error", text: (err as Error).message });
|
||||||
}
|
}
|
||||||
@@ -56,12 +58,12 @@ export function ProfileSection() {
|
|||||||
if (!accepted.includes(file.type)) {
|
if (!accepted.includes(file.type)) {
|
||||||
setMessage({
|
setMessage({
|
||||||
type: "error",
|
type: "error",
|
||||||
text: "Please select a JPG, PNG, or WebP image.",
|
text: t("common:imageUpload.invalidType"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
setMessage({ type: "error", text: "Image must be under 5MB." });
|
setMessage({ type: "error", text: t("common:imageUpload.tooLarge") });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ export function ProfileSection() {
|
|||||||
setDirty(true);
|
setDirty(true);
|
||||||
} catch {
|
} catch {
|
||||||
setAvatarDisplayUrl(null);
|
setAvatarDisplayUrl(null);
|
||||||
setMessage({ type: "error", text: "Avatar upload failed." });
|
setMessage({ type: "error", text: t("collection:profileSection.avatarUploadFailed") });
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
@@ -85,9 +87,9 @@ export function ProfileSection() {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSave} className="space-y-4">
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">Profile</h3>
|
<h3 className="text-sm font-medium text-gray-900">{t("collection:profileSection.title")}</h3>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
Your public profile information
|
{t("collection:profileSection.subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,7 +150,7 @@ export function ProfileSection() {
|
|||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
className="text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
className="text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
||||||
>
|
>
|
||||||
{uploading ? "Uploading..." : "Change avatar"}
|
{uploading ? t("collection:profileSection.uploadingAvatar") : t("collection:profileSection.changeAvatar")}
|
||||||
</button>
|
</button>
|
||||||
{avatarFilename && (
|
{avatarFilename && (
|
||||||
<button
|
<button
|
||||||
@@ -160,7 +162,7 @@ export function ProfileSection() {
|
|||||||
}}
|
}}
|
||||||
className="block text-xs text-red-500 hover:text-red-700 mt-0.5"
|
className="block text-xs text-red-500 hover:text-red-700 mt-0.5"
|
||||||
>
|
>
|
||||||
Remove
|
{t("collection:profileSection.removeAvatar")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +181,7 @@ export function ProfileSection() {
|
|||||||
htmlFor="displayName"
|
htmlFor="displayName"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
Display Name
|
{t("collection:profileSection.displayName")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="displayName"
|
id="displayName"
|
||||||
@@ -201,7 +203,7 @@ export function ProfileSection() {
|
|||||||
htmlFor="bio"
|
htmlFor="bio"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>
|
>
|
||||||
Bio
|
{t("collection:profileSection.bio")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="bio"
|
id="bio"
|
||||||
@@ -233,7 +235,7 @@ export function ProfileSection() {
|
|||||||
disabled={updateProfile.isPending}
|
disabled={updateProfile.isPending}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{updateProfile.isPending ? "Saving..." : "Save Profile"}
|
{updateProfile.isPending ? t("common:actions.saving") : t("collection:profileSection.saveProfile")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import type { SetupItemWithCategory } from "../hooks/useSetups";
|
import type { SetupItemWithCategory } from "../hooks/useSetups";
|
||||||
import { formatWeight, type WeightUnit } from "../lib/formatters";
|
import { formatWeight, type WeightUnit } from "../lib/formatters";
|
||||||
@@ -150,6 +151,7 @@ function LegendRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
||||||
|
const { t } = useTranslation("collection");
|
||||||
const { unit } = useFormatters();
|
const { unit } = useFormatters();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("category");
|
const [viewMode, setViewMode] = useState<ViewMode>("category");
|
||||||
|
|
||||||
@@ -192,9 +194,9 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
||||||
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
||||||
Weight Summary
|
{t("weightSummary.title")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-400">No weight data to display</p>
|
<p className="text-sm text-gray-400">{t("weightSummary.noData")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -203,7 +205,7 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
|||||||
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
||||||
{/* Header with pill toggle */}
|
{/* Header with pill toggle */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-medium text-gray-700">Weight Summary</h3>
|
<h3 className="text-sm font-medium text-gray-700">{t("weightSummary.title")}</h3>
|
||||||
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
|
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
|
||||||
{VIEW_MODES.map((mode) => (
|
{VIEW_MODES.map((mode) => (
|
||||||
<button
|
<button
|
||||||
@@ -216,7 +218,7 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
|||||||
: "text-gray-400 hover:text-gray-600"
|
: "text-gray-400 hover:text-gray-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{mode === "category" ? "Category" : "Classification"}
|
{mode === "category" ? t("weightSummary.category") : t("weightSummary.classification")}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -260,21 +262,21 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
|||||||
<div className="flex-1 flex flex-col justify-center min-w-0">
|
<div className="flex-1 flex flex-col justify-center min-w-0">
|
||||||
<LegendRow
|
<LegendRow
|
||||||
color="#6b7280"
|
color="#6b7280"
|
||||||
label="Base Weight"
|
label={t("weightSummary.baseWeight")}
|
||||||
weight={baseWeight}
|
weight={baseWeight}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
percent={totalWeight > 0 ? baseWeight / totalWeight : undefined}
|
percent={totalWeight > 0 ? baseWeight / totalWeight : undefined}
|
||||||
/>
|
/>
|
||||||
<LegendRow
|
<LegendRow
|
||||||
color="#9ca3af"
|
color="#9ca3af"
|
||||||
label="Worn"
|
label={t("weightSummary.worn")}
|
||||||
weight={wornWeight}
|
weight={wornWeight}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
percent={totalWeight > 0 ? wornWeight / totalWeight : undefined}
|
percent={totalWeight > 0 ? wornWeight / totalWeight : undefined}
|
||||||
/>
|
/>
|
||||||
<LegendRow
|
<LegendRow
|
||||||
color="#d1d5db"
|
color="#d1d5db"
|
||||||
label="Consumable"
|
label={t("weightSummary.consumable")}
|
||||||
weight={consumableWeight}
|
weight={consumableWeight}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
percent={
|
percent={
|
||||||
@@ -289,7 +291,7 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
|||||||
className="text-gray-400 shrink-0 ml-0.5"
|
className="text-gray-400 shrink-0 ml-0.5"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-gray-700 flex-1">
|
<span className="text-sm font-medium text-gray-700 flex-1">
|
||||||
Total
|
{t("weightSummary.total")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-bold text-gray-900 tabular-nums">
|
<span className="text-sm font-bold text-gray-900 tabular-nums">
|
||||||
{formatWeight(totalWeight, unit)}
|
{formatWeight(totalWeight, unit)}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@
|
|||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"notesPlaceholder": "Any additional notes...",
|
"notesPlaceholder": "Any additional notes...",
|
||||||
"productLink": "Product Link",
|
"productLink": "Product Link",
|
||||||
"urlPlaceholder": "https://..."
|
"urlPlaceholder": "https://...",
|
||||||
|
"msrp": "MSRP",
|
||||||
|
"purchasePrice": "Purchase Price",
|
||||||
|
"itemNamePlaceholder": "Item name",
|
||||||
|
"optionalNotes": "Optional notes..."
|
||||||
},
|
},
|
||||||
"classification": {
|
"classification": {
|
||||||
"ultralight": "Ultralight",
|
"ultralight": "Ultralight",
|
||||||
@@ -39,5 +43,73 @@
|
|||||||
"base": "Base Weight",
|
"base": "Base Weight",
|
||||||
"worn": "Worn",
|
"worn": "Worn",
|
||||||
"consumable": "Consumable"
|
"consumable": "Consumable"
|
||||||
|
},
|
||||||
|
"categoryPicker": {
|
||||||
|
"searchOrCreate": "Search or create category...",
|
||||||
|
"create": "Create",
|
||||||
|
"noCategories": "No categories found"
|
||||||
|
},
|
||||||
|
"categoryFilter": {
|
||||||
|
"allCategories": "All categories",
|
||||||
|
"searchPlaceholder": "Search categories...",
|
||||||
|
"noResults": "No categories found"
|
||||||
|
},
|
||||||
|
"weightSummary": {
|
||||||
|
"title": "Weight Summary",
|
||||||
|
"noData": "No weight data to display",
|
||||||
|
"baseWeight": "Base Weight",
|
||||||
|
"worn": "Worn",
|
||||||
|
"consumable": "Consumable",
|
||||||
|
"total": "Total",
|
||||||
|
"category": "Category",
|
||||||
|
"classification": "Classification"
|
||||||
|
},
|
||||||
|
"itemPicker": {
|
||||||
|
"title": "Select Items",
|
||||||
|
"noItems": "No items in your collection yet.",
|
||||||
|
"done": "Done"
|
||||||
|
},
|
||||||
|
"categoryHeader": {
|
||||||
|
"itemCount": "{{count}} items",
|
||||||
|
"itemCount_one": "{{count}} item",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"linkToGlobal": {
|
||||||
|
"linkToCatalog": "Link to catalog",
|
||||||
|
"linkToGlobalCatalog": "Link to global catalog",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"noItemsFound": "No items found",
|
||||||
|
"unlink": "Unlink",
|
||||||
|
"searchPlaceholder": "Search by brand or model..."
|
||||||
|
},
|
||||||
|
"manualEntry": {
|
||||||
|
"namePlaceholder": "Item name",
|
||||||
|
"weightLabel": "Weight (g)",
|
||||||
|
"msrpLabel": "MSRP ($)",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"optionalNotes": "Optional notes...",
|
||||||
|
"productLink": "Product Link",
|
||||||
|
"addToCollection": "Add to Collection",
|
||||||
|
"nameRequired": "Name is required",
|
||||||
|
"selectCategory": "Please select a category",
|
||||||
|
"failedToSave": "Failed to save"
|
||||||
|
},
|
||||||
|
"itemCard": {
|
||||||
|
"duplicateItem": "Duplicate item",
|
||||||
|
"openProductLink": "Open product link",
|
||||||
|
"removeFromSetup": "Remove from setup"
|
||||||
|
},
|
||||||
|
"profileSection": {
|
||||||
|
"title": "Profile",
|
||||||
|
"subtitle": "Your public profile information",
|
||||||
|
"changeAvatar": "Change avatar",
|
||||||
|
"removeAvatar": "Remove",
|
||||||
|
"uploadingAvatar": "Uploading...",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"bio": "Bio",
|
||||||
|
"saveProfile": "Save Profile",
|
||||||
|
"profileUpdated": "Profile updated",
|
||||||
|
"avatarUploadFailed": "Avatar upload failed."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user