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

@@ -14,9 +14,10 @@ use std::collections::HashMap;
use crate::render::render_page;
use application::import::{
apply_mapping as apply_import_mapping,
apply_profile as apply_import_profile,
commands::{
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
ExecuteImportCommand, SaveImportProfileCommand,
ApplyImportMappingCommand, ApplyImportProfileCommand, CreateImportSessionCommand,
DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand,
},
create_session as create_import_session, delete_profile as delete_import_profile,
execute as execute_import, list_profiles as list_import_profiles,
@@ -859,3 +860,96 @@ pub async fn api_delete_profile(
}
}
}
#[utoipa::path(
put, path = "/api/v1/import/sessions/{id}/profile/{profile_id}",
params(
("id" = String, Path, description = "Import session UUID"),
("profile_id" = String, Path, description = "Import profile UUID"),
),
responses(
(status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)),
(status = 400, description = "Invalid ID"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Session or profile not found"),
(status = 422, description = "Mapping error"),
),
security(("bearer_auth" = []))
)]
pub async fn api_apply_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path((session_id_str, profile_id_str)): Path<(String, String)>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str.parse::<uuid::Uuid>() else {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid session id"})),
)
.into_response();
};
let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() else {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid profile id"})),
)
.into_response();
};
if let Err(e) = apply_import_profile::execute(
&state.app_ctx,
ApplyImportProfileCommand {
user_id: user_id.value(),
session_id,
profile_id,
},
)
.await
{
let status = if matches!(e, domain::errors::DomainError::NotFound(_)) {
StatusCode::NOT_FOUND
} else {
StatusCode::UNPROCESSABLE_ENTITY
};
return (status, axum::Json(serde_json::json!({"error": e.to_string()}))).into_response();
}
let session = match state
.app_ctx
.repos
.import_session
.get(
&ImportSessionId::from_uuid(session_id),
&user_id,
)
.await
{
Ok(Some(s)) => s,
_ => {
return (
StatusCode::NOT_FOUND,
axum::Json(serde_json::json!({"error": "session not found after profile apply"})),
)
.into_response();
}
};
let mappings = session.field_mappings.unwrap_or_default();
match apply_import_mapping::execute(
&state.app_ctx,
ApplyImportMappingCommand {
user_id: user_id.value(),
session_id,
mappings,
},
)
.await
{
Ok(rows) => axum::Json(serde_json::json!({"row_count": rows.len()})).into_response(),
Err(e) => (
StatusCode::UNPROCESSABLE_ENTITY,
axum::Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}

View File

@@ -365,6 +365,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
"/import/profiles/{id}",
routing::delete(handlers::import::api_delete_profile),
)
.route(
"/import/sessions/{id}/profile/{profile_id}",
routing::put(handlers::import::api_apply_profile),
)
.route(
"/profile",
routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler),

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>