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:
2026-04-18 13:35:59 +02:00
parent 8634ca41c1
commit c5af1247c0
12 changed files with 180 additions and 87 deletions

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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">

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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)}

View File

@@ -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."
} }
} }