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:
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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": "1–10 → 1–5 (×0.5)",
|
||||
"scale1to100": "1–100 → 1–5 (×0.05)",
|
||||
"scaleLetterboxd": "0–4 Letterboxd → 1–5 (×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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user