feat: add CSV import/export for gear collection
Adds export (GET /api/items/export) and import (POST /api/items/import) routes backed by a pure csv.service with no external deps, plus useExportItems/useImportItems hooks and an Import/Export section in the Settings page. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
useApiKeys,
|
||||
useAuth,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useDeleteApiKey,
|
||||
} from "../hooks/useAuth";
|
||||
import { useCurrency } from "../hooks/useCurrency";
|
||||
import { useExportItems, useImportItems } from "../hooks/useItems";
|
||||
import { useUpdateSetting } from "../hooks/useSettings";
|
||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||
import type { Currency, WeightUnit } from "../lib/formatters";
|
||||
@@ -172,6 +173,95 @@ function ApiKeySection() {
|
||||
);
|
||||
}
|
||||
|
||||
function ImportExportSection() {
|
||||
const exportItems = useExportItems();
|
||||
const importItems = useImportItems();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importResult, setImportResult] = useState<{
|
||||
imported: number;
|
||||
createdCategories: string[];
|
||||
errors: string[];
|
||||
} | null>(null);
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImportResult(null);
|
||||
try {
|
||||
const result = await importItems.mutateAsync(file);
|
||||
setImportResult(result);
|
||||
} catch (err) {
|
||||
setImportResult({
|
||||
imported: 0,
|
||||
createdCategories: [],
|
||||
errors: [(err as Error).message],
|
||||
});
|
||||
}
|
||||
// Reset so the same file can be imported again if needed
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">Import / Export</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Export your gear collection as a CSV file, or import items from a CSV.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</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"}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
disabled={importItems.isPending}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{importResult && (
|
||||
<div
|
||||
className={`rounded-lg p-3 text-xs space-y-1 ${
|
||||
importResult.errors.length > 0 && importResult.imported === 0
|
||||
? "bg-red-50 border border-red-200 text-red-700"
|
||||
: "bg-green-50 border border-green-200 text-green-700"
|
||||
}`}
|
||||
>
|
||||
{importResult.imported > 0 && (
|
||||
<p className="font-medium">
|
||||
{importResult.imported} item
|
||||
{importResult.imported !== 1 ? "s" : ""} imported.
|
||||
</p>
|
||||
)}
|
||||
{importResult.createdCategories.length > 0 && (
|
||||
<p>New categories: {importResult.createdCategories.join(", ")}</p>
|
||||
)}
|
||||
{importResult.errors.map((err, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
|
||||
<p key={i} className="text-red-600">
|
||||
{err}
|
||||
</p>
|
||||
))}
|
||||
{importResult.imported === 0 && importResult.errors.length === 0 && (
|
||||
<p>No items found in the CSV.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
@@ -255,6 +345,10 @@ function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
||||
<ImportExportSection />
|
||||
</div>
|
||||
|
||||
{auth?.user && (
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
|
||||
<ChangePasswordSection />
|
||||
|
||||
Reference in New Issue
Block a user