feat: JSON import + mapping presets in SPA

- Accept .json files in import upload, send format to backend
- Backend endpoint PUT /import/sessions/{id}/profile/{profile_id}
- Load saved presets on mapping step, auto-apply and skip to preview
- Save current mapping as preset on confirm step
- Delete presets from mapping step
This commit is contained in:
2026-06-11 12:58:08 +02:00
parent 9a894c3a95
commit 5dc90724d3
7 changed files with 230 additions and 11 deletions

View File

@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
applyImportProfile,
applyMapping,
confirmImport,
createImportSession,
@@ -93,3 +94,10 @@ export function useDeleteImportProfile() {
},
})
}
export function useApplyImportProfile() {
return useMutation({
mutationFn: ({ sessionId, profileId }: { sessionId: string; profileId: string }) =>
applyImportProfile(sessionId, profileId),
})
}

View File

@@ -118,3 +118,17 @@ export async function upload<T>(
body: form,
})
}
export async function uploadWithFields<T>(
path: string,
file: File,
fields: Record<string, string>,
): Promise<T> {
const form = new FormData()
form.append("file", file)
for (const [k, v] of Object.entries(fields)) form.append(k, v)
return request<T>(buildUrl(path), {
method: "POST",
body: form,
})
}

View File

@@ -1,5 +1,5 @@
import { z } from "zod"
import { del, get, post, put, upload } from "./client"
import { del, get, post, put, uploadWithFields } from "./client"
export const sessionCreatedResponseSchema = z.object({
session_id: z.string(),
@@ -41,7 +41,9 @@ export const saveProfileRequestSchema = z.object({
export type SaveProfileRequest = z.infer<typeof saveProfileRequestSchema>
export function createImportSession(file: File) {
return upload<SessionCreatedResponse>("/import/sessions", file)
const ext = file.name.split(".").pop()?.toLowerCase()
const format = ext === "json" ? "json" : "csv"
return uploadWithFields<SessionCreatedResponse>("/import/sessions", file, { format })
}
export function getImportSession(id: string) {
@@ -76,14 +78,24 @@ export function confirmImport(sessionId: string, data: ConfirmRequest) {
return post(`/import/sessions/${sessionId}/confirm`, data)
}
export type ImportProfile = {
id: string
name: string
created_at: string
}
export function getImportProfiles() {
return get<unknown[]>("/import/profiles")
return get<ImportProfile[]>("/import/profiles")
}
export function saveImportProfile(data: SaveProfileRequest) {
return post("/import/profiles", data)
return post<{ id: string }>("/import/profiles", data)
}
export function deleteImportProfile(id: string) {
return del(`/import/profiles/${id}`)
}
export function applyImportProfile(sessionId: string, profileId: string) {
return put<{ row_count: number }>(`/import/sessions/${sessionId}/profile/${profileId}`)
}

View File

@@ -144,7 +144,7 @@
"editProfile": "Edit Profile",
"editProfileDesc": "Username, bio",
"import": "Import",
"importDesc": "Import from CSV",
"importDesc": "Import from CSV or JSON",
"yearWrapUp": "Year Wrap-Up",
"yearWrapUpDesc": "Annual summaries",
"webhookTokens": "Webhook Tokens",
@@ -337,7 +337,7 @@
"scale1to10": "110 → 15 (×0.5)",
"scale1to100": "1100 → 15 (×0.05)",
"scaleLetterboxd": "04 Letterboxd → 15 (×1.25)",
"dropCsv": "Drop a CSV file or tap to browse",
"dropCsv": "Drop a CSV or JSON file, or tap to browse",
"uploading": "Uploading...",
"preview": "Preview",
"rowsCols": "{{rows}} rows · {{cols}} columns",
@@ -357,6 +357,14 @@
"importing": "Importing...",
"importRows": "Import {{count}} rows",
"importComplete": "Import complete!",
"viewDiary": "View your diary"
"viewDiary": "View your diary",
"presets": "Presets",
"loadPreset": "Load preset",
"savePreset": "Save as preset",
"presetName": "Preset name",
"presetNamePlaceholder": "e.g. Letterboxd",
"presetSaved": "Preset saved",
"presetDeleted": "Preset deleted",
"noPresets": "No saved presets"
}
}

View File

@@ -1,7 +1,8 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { ArrowLeft, CheckCircle, Upload } from "lucide-react"
import { ArrowLeft, CheckCircle, Trash2, Upload } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
@@ -25,8 +26,12 @@ import { Skeleton } from "@/components/ui/skeleton"
import {
useCreateImportSession,
useApplyMapping,
useApplyImportProfile,
useConfirmImport,
useImportPreview,
useImportProfiles,
useSaveImportProfile,
useDeleteImportProfile,
} from "@/hooks/use-imports"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { SessionCreatedResponse } from "@/lib/api/imports"
@@ -65,7 +70,10 @@ function ImportPage() {
const createSession = useCreateImportSession()
const applyMapping = useApplyMapping()
const applyProfile = useApplyImportProfile()
const confirmImport = useConfirmImport()
const { data: profiles } = useImportProfiles()
const deleteProfile = useDeleteImportProfile()
const handleFile = (file: File) => {
createSession.mutate(file, {
@@ -115,6 +123,14 @@ function ImportPage() {
)
}
const handleApplyProfile = (profileId: string) => {
if (!session) return
applyProfile.mutate(
{ sessionId: session.session_id, profileId },
{ onSuccess: () => setStep(2) },
)
}
const hasRatingMapping = Object.values(mappings).includes("rating")
return (
@@ -162,7 +178,7 @@ function ImportPage() {
<input
ref={fileRef}
type="file"
accept=".csv"
accept=".csv,.json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
@@ -214,6 +230,40 @@ function ImportPage() {
</CardContent>
</Card>
{/* Presets */}
{profiles && profiles.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("import.presets")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{profiles.map((p) => (
<div key={p.id} className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 justify-start"
onClick={() => handleApplyProfile(p.id)}
disabled={applyProfile.isPending}
>
{p.name}
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground"
onClick={() => deleteProfile.mutate(p.id, {
onSuccess: () => toast.success(t("import.presetDeleted")),
})}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</CardContent>
</Card>
)}
{/* Column mapping */}
<Card>
<CardHeader>
@@ -332,6 +382,8 @@ function ConfirmStep({
}) {
const { t } = useTranslation()
const { data, isPending: previewLoading } = useImportPreview(sessionId)
const saveProfile = useSaveImportProfile()
const [presetName, setPresetName] = useState("")
if (previewLoading) return <Skeleton className="h-40 w-full rounded-xl" />
@@ -387,6 +439,33 @@ function ConfirmStep({
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("import.savePreset")}</CardTitle>
</CardHeader>
<CardContent className="flex gap-2">
<Input
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder={t("import.presetNamePlaceholder")}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
disabled={!presetName.trim() || saveProfile.isPending || saveProfile.isSuccess}
onClick={() =>
saveProfile.mutate(
{ session_id: sessionId, name: presetName.trim() },
{ onSuccess: () => toast.success(t("import.presetSaved")) },
)
}
>
{saveProfile.isSuccess ? t("import.presetSaved") : t("common.save")}
</Button>
</CardContent>
</Card>
<Button onClick={onConfirm} disabled={isPending || valid.length === 0} className="w-full">
{isPending ? t("import.importing") : t("import.importRows", { count: valid.length })}
</Button>