From e59fd98b43070898c594c35af1a0bbee604ad160 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Wed, 4 Feb 2026 14:02:05 +0000 Subject: feat(import): new import details page (#2451) * feat(import): new import details page * fix typecheck * review comments --- .../components/settings/ImportSessionDetail.tsx | 596 +++++++++++++++++++++ 1 file changed, 596 insertions(+) create mode 100644 apps/web/components/settings/ImportSessionDetail.tsx (limited to 'apps/web/components/settings/ImportSessionDetail.tsx') diff --git a/apps/web/components/settings/ImportSessionDetail.tsx b/apps/web/components/settings/ImportSessionDetail.tsx new file mode 100644 index 00000000..4b356eda --- /dev/null +++ b/apps/web/components/settings/ImportSessionDetail.tsx @@ -0,0 +1,596 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { Progress } from "@/components/ui/progress"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + useDeleteImportSession, + useImportSessionResults, + useImportSessionStats, + usePauseImportSession, + useResumeImportSession, +} from "@/lib/hooks/useImportSessions"; +import { useTranslation } from "@/lib/i18n/client"; +import { formatDistanceToNow } from "date-fns"; +import { + AlertCircle, + ArrowLeft, + CheckCircle2, + Clock, + ExternalLink, + FileText, + Globe, + Loader2, + Paperclip, + Pause, + Play, + Trash2, + Upload, +} from "lucide-react"; +import { useInView } from "react-intersection-observer"; + +import type { ZImportSessionStatus } from "@karakeep/shared/types/importSessions"; +import { switchCase } from "@karakeep/shared/utils/switch"; + +type FilterType = + | "all" + | "accepted" + | "rejected" + | "skipped_duplicate" + | "pending"; + +type SimpleTFunction = ( + key: string, + options?: Record, +) => string; + +interface ImportSessionResultItem { + id: string; + title: string | null; + url: string | null; + content: string | null; + type: string; + status: string; + result: string | null; + resultReason: string | null; + resultBookmarkId: string | null; +} + +function getStatusColor(status: string) { + switch (status) { + case "staging": + return "bg-purple-500/10 text-purple-700 dark:text-purple-400"; + case "pending": + return "bg-muted text-muted-foreground"; + case "running": + return "bg-blue-500/10 text-blue-700 dark:text-blue-400"; + case "paused": + return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400"; + case "completed": + return "bg-green-500/10 text-green-700 dark:text-green-400"; + case "failed": + return "bg-destructive/10 text-destructive"; + default: + return "bg-muted text-muted-foreground"; + } +} + +function getStatusIcon(status: string) { + switch (status) { + case "staging": + return ; + case "pending": + return ; + case "running": + return ; + case "paused": + return ; + case "completed": + return ; + case "failed": + return ; + default: + return ; + } +} + +function getResultBadge( + status: string, + result: string | null, + t: (key: string) => string, +) { + if (status === "pending") { + return ( + + + {t("settings.import_sessions.detail.result_pending")} + + ); + } + if (status === "processing") { + return ( + + + {t("settings.import_sessions.detail.result_processing")} + + ); + } + switch (result) { + case "accepted": + return ( + + + {t("settings.import_sessions.detail.result_accepted")} + + ); + case "rejected": + return ( + + + {t("settings.import_sessions.detail.result_rejected")} + + ); + case "skipped_duplicate": + return ( + + {t("settings.import_sessions.detail.result_skipped_duplicate")} + + ); + default: + return ( + + — + + ); + } +} + +function getTypeIcon(type: string) { + switch (type) { + case "link": + return ; + case "text": + return ; + case "asset": + return ; + default: + return null; + } +} + +function getTypeLabel(type: string, t: SimpleTFunction) { + switch (type) { + case "link": + return t("common.bookmark_types.link"); + case "text": + return t("common.bookmark_types.text"); + case "asset": + return t("common.bookmark_types.media"); + default: + return type; + } +} + +function getTitleDisplay( + item: { + title: string | null; + url: string | null; + content: string | null; + type: string; + }, + noTitleLabel: string, +) { + if (item.title) { + return item.title; + } + if (item.type === "text" && item.content) { + return item.content.length > 80 + ? item.content.substring(0, 80) + "…" + : item.content; + } + if (item.url) { + try { + const url = new URL(item.url); + const display = url.hostname + url.pathname; + return display.length > 60 ? display.substring(0, 60) + "…" : display; + } catch { + return item.url.length > 60 ? item.url.substring(0, 60) + "…" : item.url; + } + } + return noTitleLabel; +} + +export default function ImportSessionDetail({ + sessionId, +}: { + sessionId: string; +}) { + const { t: tRaw } = useTranslation(); + const t = tRaw as SimpleTFunction; + const router = useRouter(); + const [filter, setFilter] = useState("all"); + + const { data: stats, isLoading: isStatsLoading } = + useImportSessionStats(sessionId); + const { + data: resultsData, + isLoading: isResultsLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useImportSessionResults(sessionId, filter); + + const deleteSession = useDeleteImportSession(); + const pauseSession = usePauseImportSession(); + const resumeSession = useResumeImportSession(); + + const { ref: loadMoreRef, inView: loadMoreInView } = useInView(); + + useEffect(() => { + if (loadMoreInView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage, loadMoreInView]); + + if (isStatsLoading) { + return ; + } + + if (!stats) { + return null; + } + + const items: ImportSessionResultItem[] = + resultsData?.pages.flatMap((page) => page.items) ?? []; + + const progress = + stats.totalBookmarks > 0 + ? ((stats.completedBookmarks + stats.failedBookmarks) / + stats.totalBookmarks) * + 100 + : 0; + + const canDelete = + stats.status === "completed" || + stats.status === "failed" || + stats.status === "paused"; + const canPause = stats.status === "pending" || stats.status === "running"; + const canResume = stats.status === "paused"; + + const statusLabels = (s: ZImportSessionStatus) => + switchCase(s, { + staging: t("settings.import_sessions.status.staging"), + pending: t("settings.import_sessions.status.pending"), + running: t("settings.import_sessions.status.running"), + paused: t("settings.import_sessions.status.paused"), + completed: t("settings.import_sessions.status.completed"), + failed: t("settings.import_sessions.status.failed"), + }); + + const handleDelete = () => { + deleteSession.mutateAsync({ importSessionId: sessionId }).then(() => { + router.push("/settings/import"); + }); + }; + + return ( +
+ {/* Back link */} + + + {t("settings.import_sessions.detail.back_to_import")} + + + {/* Header */} +
+
+
+
+

{stats.name}

+

+ {t("settings.import_sessions.created_at", { + time: formatDistanceToNow(stats.createdAt, { + addSuffix: true, + }), + })} +

+
+ + {getStatusIcon(stats.status)} + + {statusLabels(stats.status)} + + +
+ + {/* Progress bar + stats */} + {stats.totalBookmarks > 0 && ( +
+
+

+ {t("settings.import_sessions.progress")} +

+
+ + {stats.completedBookmarks + stats.failedBookmarks} /{" "} + {stats.totalBookmarks} + + + {Math.round(progress)}% + +
+
+ +
+ {stats.completedBookmarks > 0 && ( + + + {t("settings.import_sessions.badges.completed", { + count: stats.completedBookmarks, + })} + + )} + {stats.failedBookmarks > 0 && ( + + + {t("settings.import_sessions.badges.failed", { + count: stats.failedBookmarks, + })} + + )} + {stats.pendingBookmarks > 0 && ( + + + {t("settings.import_sessions.badges.pending", { + count: stats.pendingBookmarks, + })} + + )} + {stats.processingBookmarks > 0 && ( + + + {t("settings.import_sessions.badges.processing", { + count: stats.processingBookmarks, + })} + + )} +
+
+ )} + + {/* Message */} + {stats.message && ( +
+ {stats.message} +
+ )} + + {/* Action buttons */} +
+
+ {canPause && ( + + )} + {canResume && ( + + )} + {canDelete && ( + + {t("settings.import_sessions.delete_dialog_description", { + name: stats.name, + })} +
+ } + actionButton={(setDialogOpen) => ( + + )} + > + + + )} +
+
+
+
+ + {/* Filter tabs + Results table */} +
+ setFilter(v as FilterType)} + className="w-full" + > + + + {t("settings.import_sessions.detail.filter_all")} + + + {t("settings.import_sessions.detail.filter_accepted")} + + + {t("settings.import_sessions.detail.filter_rejected")} + + + {t("settings.import_sessions.detail.filter_duplicates")} + + + {t("settings.import_sessions.detail.filter_pending")} + + + + + {isResultsLoading ? ( + + ) : items.length === 0 ? ( +

+ {t("settings.import_sessions.detail.no_results")} +

+ ) : ( +
+ + + + + {t("settings.import_sessions.detail.table_title")} + + + {t("settings.import_sessions.detail.table_type")} + + + {t("settings.import_sessions.detail.table_result")} + + + {t("settings.import_sessions.detail.table_reason")} + + + {t("settings.import_sessions.detail.table_bookmark")} + + + + + {items.map((item) => ( + + + {getTitleDisplay( + item, + t("settings.import_sessions.detail.no_title"), + )} + + + + {getTypeIcon(item.type)} + {getTypeLabel(item.type, t)} + + + + {getResultBadge(item.status, item.result, t)} + + + {item.resultReason || "—"} + + + {item.resultBookmarkId ? ( + + + {t("settings.import_sessions.detail.view_bookmark")} + + ) : ( + + )} + + + ))} + +
+ {hasNextPage && ( +
+ fetchNextPage()} + variant="ghost" + > + {t("settings.import_sessions.detail.load_more")} + +
+ )} +
+ )} +
+ + ); +} -- cgit v1.2.3-70-g09d2