feat(i18n): extract strings from navigation, dialogs, onboarding, settings, and login

- Add useTranslation() to TopNav, BottomTabBar, FabMenu, UserMenu
- Internationalize ConfirmDialog, AuthPromptModal, ExternalLinkDialog
- Extract all onboarding flow strings (Welcome, HobbyPicker, ItemBrowser, Review, Done)
- Internationalize settings page (weight unit, currency, API keys, import/export)
- Internationalize login page and root error boundary
- All dialogs in __root.tsx use t() for UI chrome

Phase 34, Plan 02 (core navigation and global UI)
This commit is contained in:
2026-04-13 18:19:29 +02:00
parent 8c0fb31df2
commit 672b17fd13
15 changed files with 123 additions and 98 deletions

View File

@@ -1,7 +1,9 @@
import { Link } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { useUIStore } from "../stores/uiStore";
export function AuthPromptModal() {
const { t } = useTranslation();
const showAuthPrompt = useUIStore((s) => s.showAuthPrompt);
const closeAuthPrompt = useUIStore((s) => s.closeAuthPrompt);
@@ -18,10 +20,10 @@ export function AuthPromptModal() {
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Join GearBox
{t("auth.joinGearBox")}
</h3>
<p className="text-sm text-gray-600 mb-6">
To manage your own collection, sign in or sign up.
{t("auth.signInDescription")}
</p>
<div className="flex flex-col gap-3">
<Link
@@ -29,14 +31,14 @@ export function AuthPromptModal() {
className="w-full text-center px-4 py-2.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
onClick={closeAuthPrompt}
>
Sign in
{t("auth.signIn")}
</Link>
<Link
to="/login"
className="w-full text-center px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
onClick={closeAuthPrompt}
>
Create account
{t("auth.createAccount")}
</Link>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { Link, useMatchRoute } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import { useAuth } from "../hooks/useAuth";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
@@ -26,6 +27,7 @@ function TabItemWrapper({ icon, label, isActive }: TabItemProps) {
}
export function BottomTabBar() {
const { t } = useTranslation();
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
@@ -46,7 +48,7 @@ export function BottomTabBar() {
<div className="flex justify-around">
{/* Home tab — always a Link */}
<Link to="/">
<TabItemWrapper icon="house" label="Home" isActive={isHome} />
<TabItemWrapper icon="house" label={t("nav.home")} isActive={isHome} />
</Link>
{/* Collection tab — Link if authenticated, button if anonymous */}
@@ -54,7 +56,7 @@ export function BottomTabBar() {
<Link to="/collection">
<TabItemWrapper
icon="package"
label="Collection"
label={t("nav.collection")}
isActive={isCollection}
/>
</Link>
@@ -62,7 +64,7 @@ export function BottomTabBar() {
<button type="button" onClick={openAuthPrompt}>
<TabItemWrapper
icon="package"
label="Collection"
label={t("nav.collection")}
isActive={isCollection}
/>
</button>
@@ -71,17 +73,17 @@ export function BottomTabBar() {
{/* Setups tab — Link if authenticated, button if anonymous */}
{isAuthenticated ? (
<Link to="/setups">
<TabItemWrapper icon="layers" label="Setups" isActive={isSetups} />
<TabItemWrapper icon="layers" label={t("nav.setups")} isActive={isSetups} />
</Link>
) : (
<button type="button" onClick={openAuthPrompt}>
<TabItemWrapper icon="layers" label="Setups" isActive={isSetups} />
<TabItemWrapper icon="layers" label={t("nav.setups")} isActive={isSetups} />
</button>
)}
{/* Search tab — always a button, opens CatalogSearchOverlay */}
<button type="button" onClick={() => openCatalogSearch("collection")}>
<TabItemWrapper icon="search" label="Search" isActive={false} />
<TabItemWrapper icon="search" label={t("nav.search")} isActive={false} />
</button>
</div>
</motion.div>

View File

@@ -1,7 +1,9 @@
import { useTranslation } from "react-i18next";
import { useDeleteItem, useItems } from "../hooks/useItems";
import { useUIStore } from "../stores/uiStore";
export function ConfirmDialog() {
const { t } = useTranslation();
const confirmDeleteItemId = useUIStore((s) => s.confirmDeleteItemId);
const closeConfirmDelete = useUIStore((s) => s.closeConfirmDelete);
const deleteItem = useDeleteItem();
@@ -30,12 +32,10 @@ export function ConfirmDialog() {
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Delete Item
{t("confirm.deleteItem")}
</h3>
<p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "}
<span className="font-medium">{itemName}</span>? This action cannot be
undone.
{t("confirm.deleteItemMessage", { name: itemName })}
</p>
<div className="flex justify-end gap-3">
<button
@@ -43,7 +43,7 @@ export function ConfirmDialog() {
onClick={closeConfirmDelete}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
{t("actions.cancel")}
</button>
<button
type="button"
@@ -51,7 +51,7 @@ export function ConfirmDialog() {
disabled={deleteItem.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
>
{deleteItem.isPending ? "Deleting..." : "Delete"}
{deleteItem.isPending ? t("actions.deleting") : t("actions.delete")}
</button>
</div>
</div>

View File

@@ -1,7 +1,9 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useUIStore } from "../stores/uiStore";
export function ExternalLinkDialog() {
const { t } = useTranslation();
const externalLinkUrl = useUIStore((s) => s.externalLinkUrl);
const closeExternalLink = useUIStore((s) => s.closeExternalLink);
@@ -35,9 +37,9 @@ export function ExternalLinkDialog() {
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
You are about to leave GearBox
{t("externalLink.title")}
</h3>
<p className="text-sm text-gray-600 mb-1">You will be redirected to:</p>
<p className="text-sm text-gray-600 mb-1">{t("externalLink.redirectMessage")}</p>
<p className="text-sm text-gray-600 break-all mb-6">
{externalLinkUrl}
</p>
@@ -47,14 +49,14 @@ export function ExternalLinkDialog() {
onClick={closeExternalLink}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
{t("actions.cancel")}
</button>
<button
type="button"
onClick={handleContinue}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
>
Continue
{t("actions.continue")}
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { AnimatePresence, motion } from "framer-motion";
import { Package, Plus, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useUIStore } from "../stores/uiStore";
interface FabMenuProps {
@@ -15,6 +16,7 @@ interface MenuItem {
}
export function FabMenu({ isSetupsPage }: FabMenuProps) {
const { t } = useTranslation();
const fabMenuOpen = useUIStore((s) => s.fabMenuOpen);
const openFabMenu = useUIStore((s) => s.openFabMenu);
const closeFabMenu = useUIStore((s) => s.closeFabMenu);
@@ -26,12 +28,12 @@ export function FabMenu({ isSetupsPage }: FabMenuProps) {
const menuItems: MenuItem[] = [
{
label: "Add to Collection",
label: t("fab.addToCollection"),
icon: <Package className="w-5 h-5 text-gray-600" />,
onClick: () => openCatalogSearch("collection"),
},
{
label: "Start New Thread",
label: t("fab.startNewThread"),
icon: <Search className="w-5 h-5 text-gray-600" />,
onClick: () => openCatalogSearch("thread"),
},
@@ -39,7 +41,7 @@ export function FabMenu({ isSetupsPage }: FabMenuProps) {
if (isSetupsPage) {
menuItems.push({
label: "New Setup",
label: t("fab.newSetup"),
icon: <Plus className="w-5 h-5 text-gray-600" />,
onClick: () => {
closeFabMenu();

View File

@@ -1,5 +1,6 @@
import { Link, useMatchRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../hooks/useAuth";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
@@ -42,6 +43,7 @@ function NavLinkOrButton({
}
export function TopNav() {
const { t } = useTranslation();
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
@@ -82,7 +84,7 @@ export function TopNav() {
isAuthenticated={isAuthenticated}
onAuthPrompt={openAuthPrompt}
>
Home
{t("nav.home")}
</NavLinkOrButton>
<NavLinkOrButton
to="/collection"
@@ -91,7 +93,7 @@ export function TopNav() {
isAuthenticated={isAuthenticated}
onAuthPrompt={openAuthPrompt}
>
Collection
{t("nav.collection")}
</NavLinkOrButton>
<NavLinkOrButton
to="/setups"
@@ -100,7 +102,7 @@ export function TopNav() {
isAuthenticated={isAuthenticated}
onAuthPrompt={openAuthPrompt}
>
Setups
{t("nav.setups")}
</NavLinkOrButton>
</nav>
@@ -124,7 +126,7 @@ export function TopNav() {
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
placeholder="Search catalog..."
placeholder={t("nav.searchPlaceholder")}
className="bg-gray-50 border border-gray-200 rounded-lg pl-9 pr-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-200 focus:border-gray-300 w-48 lg:w-64 transition-colors"
/>
</div>
@@ -137,7 +139,7 @@ export function TopNav() {
to="/login"
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
Sign in
{t("auth.signIn")}
</Link>
)}
</div>

View File

@@ -1,10 +1,12 @@
import { Link } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth, useLogout } from "../hooks/useAuth";
import { usePublicProfile } from "../hooks/useProfile";
import { LucideIcon } from "../lib/iconData";
export function UserMenu() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { logout } = useLogout();
@@ -34,7 +36,7 @@ export function UserMenu() {
{avatarUrl ? (
<img
src={avatarUrl}
alt="Profile"
alt={t("nav.profile")}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
@@ -53,7 +55,7 @@ export function UserMenu() {
size={16}
className="text-gray-400"
/>
Profile
{t("nav.profile")}
</Link>
<Link
to="/settings"
@@ -61,7 +63,7 @@ export function UserMenu() {
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<LucideIcon name="settings" size={16} className="text-gray-400" />
Settings
{t("nav.settings")}
</Link>
<div className="border-t border-gray-100 my-1" />
<button
@@ -73,7 +75,7 @@ export function UserMenu() {
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<LucideIcon name="log-out" size={16} className="text-gray-400" />
Sign out
{t("auth.signOut")}
</button>
</div>
)}

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { LucideIcon } from "../../lib/iconData";
interface OnboardingDoneProps {
@@ -9,6 +10,7 @@ export function OnboardingDone({
itemsCreated: _itemsCreated,
onFinish,
}: OnboardingDoneProps) {
const { t } = useTranslation("onboarding");
return (
<div className="flex flex-col items-center justify-center min-h-screen px-8">
<div className="max-w-2xl text-center">
@@ -20,18 +22,17 @@ export function OnboardingDone({
/>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
You're all set!
{t("done.title")}
</h1>
<p className="text-base text-gray-500 mb-8">
Your collection is ready. Browse the catalog anytime to discover more
gear.
{t("done.subtitle")}
</p>
<button
type="button"
onClick={onFinish}
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
>
Start exploring
{t("done.cta")}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { HOBBIES } from "@/shared/hobbyConfig";
import { useTranslation } from "react-i18next";
import { HobbyCard } from "./HobbyCard";
interface OnboardingHobbyPickerProps {
@@ -12,14 +13,15 @@ export function OnboardingHobbyPicker({
onToggleHobby,
onContinue,
}: OnboardingHobbyPickerProps) {
const { t } = useTranslation("onboarding");
return (
<div className="flex flex-col items-center justify-center min-h-screen px-8">
<div className="max-w-2xl text-center">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
What are you into?
{t("hobby.title")}
</h1>
<p className="text-base text-gray-500 mb-8">
Pick one or more we'll show you popular gear for each.
{t("hobby.subtitle")}
</p>
<div className="flex flex-wrap justify-center gap-4 mb-8">
{HOBBIES.map((hobby) => (
@@ -39,7 +41,7 @@ export function OnboardingHobbyPicker({
disabled={selectedHobbies.length === 0}
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
Continue
{t("hobby.continue")}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { getTagsForHobbies } from "@/shared/hobbyConfig";
import { useTranslation } from "react-i18next";
import { usePopularItems } from "../../hooks/useOnboarding";
import { SelectableItemCard } from "./SelectableItemCard";
@@ -17,6 +18,7 @@ export function OnboardingItemBrowser({
onContinue,
onSkip,
}: OnboardingItemBrowserProps) {
const { t } = useTranslation("onboarding");
const tags = getTagsForHobbies(selectedHobbies);
const { data: items, isLoading } = usePopularItems(tags);
@@ -48,11 +50,12 @@ export function OnboardingItemBrowser({
<div className="flex flex-col items-center min-h-screen px-8 py-16">
<div className="max-w-5xl w-full text-center">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Popular gear for{" "}
{selectedHobbies.length === 1 ? selectedHobbies[0] : "your hobbies"}
{selectedHobbies.length === 1
? t("items.title", { hobby: selectedHobbies[0] })
: t("items.titleMultiple")}
</h1>
<p className="text-base text-gray-500 mb-8">
Tap items you already own. We'll add them to your collection.
{t("items.subtitle")}
</p>
{isLoading && (
@@ -64,11 +67,10 @@ export function OnboardingItemBrowser({
{!isLoading && !hasItems && (
<div className="py-12 text-center">
<h2 className="text-lg font-semibold text-gray-900 mb-2">
No gear cataloged yet
{t("items.noCatalog")}
</h2>
<p className="text-base text-gray-500 mb-8">
We're still building our catalog for this hobby. You can skip this
step and add gear manually later.
{t("items.noCatalogDescription")}
</p>
</div>
)}
@@ -105,8 +107,7 @@ export function OnboardingItemBrowser({
onClick={onContinue}
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
>
Review {selectedItemIds.size}{" "}
{selectedItemIds.size === 1 ? "item" : "items"}
{t("items.reviewCount", { count: selectedItemIds.size })}
</button>
)}
<button
@@ -114,7 +115,7 @@ export function OnboardingItemBrowser({
onClick={onSkip}
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
Skip this step
{t("common:actions.skipStep")}
</button>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { LucideIcon } from "../../lib/iconData";
interface ReviewItem {
@@ -23,6 +24,7 @@ export function OnboardingReview({
onSkip,
isSubmitting,
}: OnboardingReviewProps) {
const { t } = useTranslation("onboarding");
// Group by category
const grouped = new Map<string, ReviewItem[]>();
for (const item of items) {
@@ -35,12 +37,12 @@ export function OnboardingReview({
<div className="flex flex-col items-center justify-center min-h-screen px-8">
<div className="max-w-2xl w-full text-center">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Your starting collection
{t("review.title")}
</h1>
<p className="text-base text-gray-500 mb-8">
{items.length > 0
? `${items.length} ${items.length === 1 ? "item" : "items"} ready to add`
: "No items selected — you can always add gear later from the catalog."}
? t("review.itemsReady", { count: items.length })
: t("review.noItemsSelected")}
</p>
{items.length > 0 && (
@@ -101,7 +103,7 @@ export function OnboardingReview({
disabled={isSubmitting}
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
>
{isSubmitting ? "Adding..." : "Add to my collection"}
{isSubmitting ? t("review.adding") : t("review.addToCollection")}
</button>
) : (
<button
@@ -109,7 +111,7 @@ export function OnboardingReview({
onClick={onSkip}
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
>
Continue
{t("common:actions.continue")}
</button>
)}
{items.length > 0 && (
@@ -118,7 +120,7 @@ export function OnboardingReview({
onClick={onSkip}
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
Skip this step
{t("common:actions.skipStep")}
</button>
)}
</div>

View File

@@ -1,24 +1,26 @@
import { useTranslation } from "react-i18next";
interface OnboardingWelcomeProps {
onContinue: () => void;
}
export function OnboardingWelcome({ onContinue }: OnboardingWelcomeProps) {
const { t } = useTranslation("onboarding");
return (
<div className="flex flex-col items-center justify-center min-h-screen px-8">
<div className="max-w-2xl text-center">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Welcome to GearBox
{t("welcome.title")}
</h1>
<p className="text-base text-gray-500 mb-8 leading-relaxed">
Tell us what you're into, and we'll help you set up your collection
with gear that people actually use.
{t("welcome.subtitle")}
</p>
<button
type="button"
onClick={onContinue}
className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
>
Let's go
{t("welcome.cta")}
</button>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import {
useRouter,
} from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Toaster } from "sonner";
import "../app.css";
import { AddToCollectionModal } from "../components/AddToCollectionModal";
@@ -33,6 +34,7 @@ export const Route = createRootRoute({
function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
const router = useRouter();
const { t } = useTranslation();
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
@@ -53,12 +55,12 @@ function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
</svg>
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">
Something went wrong
{t("errors.somethingWentWrong")}
</h1>
<p className="text-sm text-gray-500 mb-6">
{error instanceof Error
? error.message
: "An unexpected error occurred"}
: t("errors.unexpectedError")}
</p>
<button
type="button"
@@ -68,7 +70,7 @@ function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
}}
className="px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
Try again
{t("actions.tryAgain")}
</button>
</div>
</div>
@@ -205,6 +207,7 @@ function CandidateDeleteDialog({
threadId: number;
onClose: () => void;
}) {
const { t } = useTranslation();
const deleteCandidate = useDeleteCandidate(threadId);
const { data: thread } = useThread(threadId);
const candidate = thread?.candidates.find((c) => c.id === candidateId);
@@ -227,12 +230,10 @@ function CandidateDeleteDialog({
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Delete Candidate
{t("confirm.deleteCandidate")}
</h3>
<p className="text-sm text-gray-600 mb-6">
Are you sure you want to delete{" "}
<span className="font-medium">{candidateName}</span>? This action
cannot be undone.
{t("confirm.deleteCandidateMessage", { name: candidateName })}
</p>
<div className="flex justify-end gap-3">
<button
@@ -240,7 +241,7 @@ function CandidateDeleteDialog({
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
{t("actions.cancel")}
</button>
<button
type="button"
@@ -248,7 +249,7 @@ function CandidateDeleteDialog({
disabled={deleteCandidate.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
>
{deleteCandidate.isPending ? "Deleting..." : "Delete"}
{deleteCandidate.isPending ? t("actions.deleting") : t("actions.delete")}
</button>
</div>
</div>
@@ -267,6 +268,7 @@ function ResolveDialog({
onClose: () => void;
onResolved: () => void;
}) {
const { t } = useTranslation();
const resolveThread = useResolveThread();
const { data: thread } = useThread(threadId);
const candidate = thread?.candidates.find((c) => c.id === candidateId);
@@ -290,11 +292,10 @@ function ResolveDialog({
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Pick Winner
{t("confirm.pickWinner")}
</h3>
<p className="text-sm text-gray-600 mb-6">
Pick <span className="font-medium">{candidateName}</span> as the
winner? This will add it to your collection and archive the thread.
{t("confirm.pickWinnerMessage", { name: candidateName })}
</p>
<div className="flex justify-end gap-3">
<button
@@ -302,7 +303,7 @@ function ResolveDialog({
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Cancel
{t("actions.cancel")}
</button>
<button
type="button"
@@ -310,7 +311,7 @@ function ResolveDialog({
disabled={resolveThread.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-lg transition-colors"
>
{resolveThread.isPending ? "Resolving..." : "Pick Winner"}
{resolveThread.isPending ? t("actions.saving") : t("confirm.pickWinner")}
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../hooks/useAuth";
export const Route = createFileRoute("/login")({
@@ -7,6 +8,7 @@ export const Route = createFileRoute("/login")({
});
function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: auth, isLoading } = useAuth();
@@ -19,7 +21,7 @@ function LoginPage() {
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<p className="text-gray-500 text-sm">Loading...</p>
<p className="text-gray-500 text-sm">{t("actions.loading")}</p>
</div>
);
}
@@ -28,11 +30,11 @@ function LoginPage() {
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="text-xl font-semibold text-gray-900 text-center mb-6">
Sign in to GearBox
{t("auth.signInToGearBox")}
</h1>
<div className="bg-white rounded-xl border border-gray-100 p-6 space-y-4">
<p className="text-sm text-gray-500 text-center">
You will be redirected to sign in with your account.
{t("auth.redirectDescription")}
</p>
<button
type="button"
@@ -41,7 +43,7 @@ function LoginPage() {
}}
className="w-full py-2 px-4 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
>
Sign In
{t("auth.signIn")}
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
useApiKeys,
useAuth,
@@ -27,6 +28,7 @@ export const Route = createFileRoute("/settings")({
});
function ApiKeySection() {
const { t } = useTranslation("settings");
const { data: keys } = useApiKeys();
const createKey = useCreateApiKey();
const deleteKey = useDeleteApiKey();
@@ -42,16 +44,15 @@ function ApiKeySection() {
return (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900">API Keys</h3>
<h3 className="text-sm font-medium text-gray-900">{t("apiKeys.title")}</h3>
<p className="text-xs text-gray-500">
API keys allow programmatic access to GearBox (e.g., from Claude Desktop
or scripts).
{t("apiKeys.description")}
</p>
{newKey && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs font-medium text-amber-800 mb-1">
Copy this key now it won't be shown again:
{t("apiKeys.copyWarning")}
</p>
<code className="text-xs text-amber-900 break-all select-all">
{newKey}
@@ -61,7 +62,7 @@ function ApiKeySection() {
onClick={() => setNewKey(null)}
className="mt-2 block text-xs text-amber-700 hover:text-amber-900"
>
Dismiss
{t("common:actions.dismiss")}
</button>
</div>
)}
@@ -69,7 +70,7 @@ function ApiKeySection() {
<form onSubmit={handleCreate} className="flex gap-2">
<input
type="text"
placeholder="Key name (e.g., claude-desktop)"
placeholder={t("apiKeys.namePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
required
@@ -80,7 +81,7 @@ function ApiKeySection() {
disabled={createKey.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"
>
Create
{t("common:actions.create")}
</button>
</form>
@@ -102,7 +103,7 @@ function ApiKeySection() {
onClick={() => deleteKey.mutate(key.id)}
className="text-xs text-red-500 hover:text-red-700"
>
Revoke
{t("common:actions.revoke")}
</button>
</div>
))}
@@ -113,6 +114,7 @@ function ApiKeySection() {
}
function ImportExportSection() {
const { t } = useTranslation("settings");
const exportItems = useExportItems();
const importItems = useImportItems();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -142,9 +144,9 @@ function ImportExportSection() {
return (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900">Import / Export</h3>
<h3 className="text-sm font-medium text-gray-900">{t("importExport.title")}</h3>
<p className="text-xs text-gray-500">
Export your gear collection as a CSV file, or import items from a CSV.
{t("importExport.description")}
</p>
<div className="flex flex-wrap gap-2">
@@ -153,11 +155,11 @@ function ImportExportSection() {
onClick={exportItems}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
>
Export CSV
{t("importExport.export")}
</button>
<label className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 rounded-lg transition-colors cursor-pointer">
{importItems.isPending ? "Importing..." : "Import CSV"}
{importItems.isPending ? t("importExport.importing") : t("importExport.import")}
<input
ref={fileInputRef}
type="file"
@@ -179,12 +181,11 @@ function ImportExportSection() {
>
{importResult.imported > 0 && (
<p className="font-medium">
{importResult.imported} item
{importResult.imported !== 1 ? "s" : ""} imported.
{t("importExport.imported", { count: importResult.imported })}
</p>
)}
{importResult.createdCategories.length > 0 && (
<p>New categories: {importResult.createdCategories.join(", ")}</p>
<p>{t("importExport.newCategories", { categories: importResult.createdCategories.join(", ") })}</p>
)}
{importResult.errors.map((err, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
@@ -193,7 +194,7 @@ function ImportExportSection() {
</p>
))}
{importResult.imported === 0 && importResult.errors.length === 0 && (
<p>No items found in the CSV.</p>
<p>{t("importExport.noItemsFound")}</p>
)}
</div>
)}
@@ -228,6 +229,7 @@ function getSuggestedCurrency(): Currency | null {
}
function SettingsPage() {
const { t } = useTranslation("settings");
const unit = useWeightUnit();
const { currency, showConversions } = useCurrency();
const updateSetting = useUpdateSetting();
@@ -245,9 +247,9 @@ function SettingsPage() {
to="/"
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
>
&larr; Back
&larr; {t("common:actions.back")}
</Link>
<h1 className="text-xl font-semibold text-gray-900">Settings</h1>
<h1 className="text-xl font-semibold text-gray-900">{t("title")}</h1>
</div>
{showSuggestion && (
@@ -279,9 +281,9 @@ function SettingsPage() {
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">Weight Unit</h3>
<h3 className="text-sm font-medium text-gray-900">{t("weightUnit.title")}</h3>
<p className="text-xs text-gray-500 mt-0.5">
Choose the unit used to display weights across the app
{t("weightUnit.description")}
</p>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
@@ -312,10 +314,10 @@ function SettingsPage() {
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">
Market & Currency
{t("currency.title")}
</h3>
<p className="text-xs text-gray-500 mt-0.5">
Sets your market region and currency for price display
{t("currency.description")}
</p>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">