diff options
| author | Mohamed Bassem <me@mbassem.com> | 2026-02-04 14:02:05 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-04 14:02:05 +0000 |
| commit | e59fd98b43070898c594c35af1a0bbee604ad160 (patch) | |
| tree | 11239ffd3c5325a3d7a913e0b44fc70ae4cf8d31 | |
| parent | 6e5820311cca68987d0fe6e50c95d4c9f4f5ce13 (diff) | |
| download | karakeep-e59fd98b43070898c594c35af1a0bbee604ad160.tar.zst | |
feat(import): new import details page (#2451)
* feat(import): new import details page
* fix typecheck
* review comments
| -rw-r--r-- | apps/web/app/settings/import/[sessionId]/page.tsx | 20 | ||||
| -rw-r--r-- | apps/web/components/settings/ImportSessionCard.tsx | 6 | ||||
| -rw-r--r-- | apps/web/components/settings/ImportSessionDetail.tsx | 596 | ||||
| -rw-r--r-- | apps/web/lib/hooks/useImportSessions.ts | 20 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 26 |
5 files changed, 666 insertions, 2 deletions
diff --git a/apps/web/app/settings/import/[sessionId]/page.tsx b/apps/web/app/settings/import/[sessionId]/page.tsx new file mode 100644 index 00000000..968de13a --- /dev/null +++ b/apps/web/app/settings/import/[sessionId]/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; +import ImportSessionDetail from "@/components/settings/ImportSessionDetail"; +import { useTranslation } from "@/lib/i18n/server"; + +export async function generateMetadata(): Promise<Metadata> { + // oxlint-disable-next-line rules-of-hooks + const { t } = await useTranslation(); + return { + title: `${t("settings.import_sessions.detail.page_title")} | Karakeep`, + }; +} + +export default async function ImportSessionDetailPage({ + params, +}: { + params: Promise<{ sessionId: string }>; +}) { + const { sessionId } = await params; + return <ImportSessionDetail sessionId={sessionId} />; +} diff --git a/apps/web/components/settings/ImportSessionCard.tsx b/apps/web/components/settings/ImportSessionCard.tsx index f20710ca..f62a00dd 100644 --- a/apps/web/components/settings/ImportSessionCard.tsx +++ b/apps/web/components/settings/ImportSessionCard.tsx @@ -242,6 +242,12 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { {/* Actions */} <div className="flex items-center justify-end pt-2"> <div className="flex items-center gap-2"> + <Button variant="outline" size="sm" asChild> + <Link href={`/settings/import/${session.id}`}> + <ExternalLink className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.view_details")} + </Link> + </Button> {canPause && ( <Button variant="outline" 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, unknown>, +) => 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 <Upload className="h-4 w-4" />; + case "pending": + return <Clock className="h-4 w-4" />; + case "running": + return <Loader2 className="h-4 w-4 animate-spin" />; + case "paused": + return <Pause className="h-4 w-4" />; + case "completed": + return <CheckCircle2 className="h-4 w-4" />; + case "failed": + return <AlertCircle className="h-4 w-4" />; + default: + return <Clock className="h-4 w-4" />; + } +} + +function getResultBadge( + status: string, + result: string | null, + t: (key: string) => string, +) { + if (status === "pending") { + return ( + <Badge + variant="secondary" + className="bg-muted text-muted-foreground hover:bg-muted" + > + <Clock className="mr-1 h-3 w-3" /> + {t("settings.import_sessions.detail.result_pending")} + </Badge> + ); + } + if (status === "processing") { + return ( + <Badge + variant="secondary" + className="bg-blue-500/10 text-blue-700 hover:bg-blue-500/10 dark:text-blue-400" + > + <Loader2 className="mr-1 h-3 w-3 animate-spin" /> + {t("settings.import_sessions.detail.result_processing")} + </Badge> + ); + } + switch (result) { + case "accepted": + return ( + <Badge + variant="secondary" + className="bg-green-500/10 text-green-700 hover:bg-green-500/10 dark:text-green-400" + > + <CheckCircle2 className="mr-1 h-3 w-3" /> + {t("settings.import_sessions.detail.result_accepted")} + </Badge> + ); + case "rejected": + return ( + <Badge + variant="secondary" + className="bg-destructive/10 text-destructive hover:bg-destructive/10" + > + <AlertCircle className="mr-1 h-3 w-3" /> + {t("settings.import_sessions.detail.result_rejected")} + </Badge> + ); + case "skipped_duplicate": + return ( + <Badge + variant="secondary" + className="bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400" + > + {t("settings.import_sessions.detail.result_skipped_duplicate")} + </Badge> + ); + default: + return ( + <Badge variant="secondary" className="bg-muted hover:bg-muted"> + — + </Badge> + ); + } +} + +function getTypeIcon(type: string) { + switch (type) { + case "link": + return <Globe className="h-3 w-3" />; + case "text": + return <FileText className="h-3 w-3" />; + case "asset": + return <Paperclip className="h-3 w-3" />; + 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<FilterType>("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 <FullPageSpinner />; + } + + 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 ( + <div className="flex flex-col gap-6"> + {/* Back link */} + <Link + href="/settings/import" + className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground" + > + <ArrowLeft className="h-4 w-4" /> + {t("settings.import_sessions.detail.back_to_import")} + </Link> + + {/* Header */} + <div className="rounded-md border bg-background p-4"> + <div className="flex flex-col gap-4"> + <div className="flex items-start justify-between"> + <div className="flex-1"> + <h2 className="text-lg font-medium">{stats.name}</h2> + <p className="mt-1 text-sm text-muted-foreground"> + {t("settings.import_sessions.created_at", { + time: formatDistanceToNow(stats.createdAt, { + addSuffix: true, + }), + })} + </p> + </div> + <Badge + className={`${getStatusColor(stats.status)} hover:bg-inherit`} + > + {getStatusIcon(stats.status)} + <span className="ml-1 capitalize"> + {statusLabels(stats.status)} + </span> + </Badge> + </div> + + {/* Progress bar + stats */} + {stats.totalBookmarks > 0 && ( + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <h4 className="text-sm font-medium text-muted-foreground"> + {t("settings.import_sessions.progress")} + </h4> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium"> + {stats.completedBookmarks + stats.failedBookmarks} /{" "} + {stats.totalBookmarks} + </span> + <Badge variant="outline" className="text-xs"> + {Math.round(progress)}% + </Badge> + </div> + </div> + <Progress value={progress} className="h-3" /> + <div className="flex flex-wrap gap-2"> + {stats.completedBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-green-500/10 text-green-700 hover:bg-green-500/10 dark:text-green-400" + > + <CheckCircle2 className="mr-1.5 h-3 w-3" /> + {t("settings.import_sessions.badges.completed", { + count: stats.completedBookmarks, + })} + </Badge> + )} + {stats.failedBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-destructive/10 text-destructive hover:bg-destructive/10" + > + <AlertCircle className="mr-1.5 h-3 w-3" /> + {t("settings.import_sessions.badges.failed", { + count: stats.failedBookmarks, + })} + </Badge> + )} + {stats.pendingBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400" + > + <Clock className="mr-1.5 h-3 w-3" /> + {t("settings.import_sessions.badges.pending", { + count: stats.pendingBookmarks, + })} + </Badge> + )} + {stats.processingBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-blue-500/10 text-blue-700 hover:bg-blue-500/10 dark:text-blue-400" + > + <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> + {t("settings.import_sessions.badges.processing", { + count: stats.processingBookmarks, + })} + </Badge> + )} + </div> + </div> + )} + + {/* Message */} + {stats.message && ( + <div className="rounded-lg border bg-muted/50 p-3 text-sm text-muted-foreground dark:bg-muted/20"> + {stats.message} + </div> + )} + + {/* Action buttons */} + <div className="flex items-center justify-end"> + <div className="flex items-center gap-2"> + {canPause && ( + <Button + variant="outline" + size="sm" + onClick={() => + pauseSession.mutate({ importSessionId: sessionId }) + } + disabled={pauseSession.isPending} + > + <Pause className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.pause_session")} + </Button> + )} + {canResume && ( + <Button + variant="outline" + size="sm" + onClick={() => + resumeSession.mutate({ importSessionId: sessionId }) + } + disabled={resumeSession.isPending} + > + <Play className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.resume_session")} + </Button> + )} + {canDelete && ( + <ActionConfirmingDialog + title={t("settings.import_sessions.delete_dialog_title")} + description={ + <div> + {t("settings.import_sessions.delete_dialog_description", { + name: stats.name, + })} + </div> + } + actionButton={(setDialogOpen) => ( + <Button + variant="destructive" + onClick={() => { + handleDelete(); + setDialogOpen(false); + }} + disabled={deleteSession.isPending} + > + {t("settings.import_sessions.delete_session")} + </Button> + )} + > + <Button + variant="destructive" + size="sm" + disabled={deleteSession.isPending} + > + <Trash2 className="mr-1 h-4 w-4" /> + {t("actions.delete")} + </Button> + </ActionConfirmingDialog> + )} + </div> + </div> + </div> + </div> + + {/* Filter tabs + Results table */} + <div className="rounded-md border bg-background p-4"> + <Tabs + value={filter} + onValueChange={(v) => setFilter(v as FilterType)} + className="w-full" + > + <TabsList className="mb-4 flex w-full flex-wrap"> + <TabsTrigger value="all"> + {t("settings.import_sessions.detail.filter_all")} + </TabsTrigger> + <TabsTrigger value="accepted"> + {t("settings.import_sessions.detail.filter_accepted")} + </TabsTrigger> + <TabsTrigger value="rejected"> + {t("settings.import_sessions.detail.filter_rejected")} + </TabsTrigger> + <TabsTrigger value="skipped_duplicate"> + {t("settings.import_sessions.detail.filter_duplicates")} + </TabsTrigger> + <TabsTrigger value="pending"> + {t("settings.import_sessions.detail.filter_pending")} + </TabsTrigger> + </TabsList> + </Tabs> + + {isResultsLoading ? ( + <FullPageSpinner /> + ) : items.length === 0 ? ( + <p className="rounded-md bg-muted p-4 text-center text-sm text-muted-foreground"> + {t("settings.import_sessions.detail.no_results")} + </p> + ) : ( + <div className="flex flex-col gap-2"> + <Table> + <TableHeader> + <TableRow> + <TableHead> + {t("settings.import_sessions.detail.table_title")} + </TableHead> + <TableHead className="w-[80px]"> + {t("settings.import_sessions.detail.table_type")} + </TableHead> + <TableHead className="w-[120px]"> + {t("settings.import_sessions.detail.table_result")} + </TableHead> + <TableHead> + {t("settings.import_sessions.detail.table_reason")} + </TableHead> + <TableHead className="w-[100px]"> + {t("settings.import_sessions.detail.table_bookmark")} + </TableHead> + </TableRow> + </TableHeader> + <TableBody> + {items.map((item) => ( + <TableRow key={item.id}> + <TableCell className="max-w-[300px] truncate font-medium"> + {getTitleDisplay( + item, + t("settings.import_sessions.detail.no_title"), + )} + </TableCell> + <TableCell> + <Badge + variant="outline" + className="flex w-fit items-center gap-1 text-xs" + > + {getTypeIcon(item.type)} + {getTypeLabel(item.type, t)} + </Badge> + </TableCell> + <TableCell> + {getResultBadge(item.status, item.result, t)} + </TableCell> + <TableCell className="max-w-[200px] truncate text-sm text-muted-foreground"> + {item.resultReason || "—"} + </TableCell> + <TableCell> + {item.resultBookmarkId ? ( + <Link + href={`/dashboard/preview/${item.resultBookmarkId}`} + className="flex items-center gap-1 text-sm text-primary hover:text-primary/80" + prefetch={false} + > + <ExternalLink className="h-3 w-3" /> + {t("settings.import_sessions.detail.view_bookmark")} + </Link> + ) : ( + <span className="text-sm text-muted-foreground">—</span> + )} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + {hasNextPage && ( + <div className="flex justify-center"> + <ActionButton + ref={loadMoreRef} + ignoreDemoMode={true} + loading={isFetchingNextPage} + onClick={() => fetchNextPage()} + variant="ghost" + > + {t("settings.import_sessions.detail.load_more")} + </ActionButton> + </div> + )} + </div> + )} + </div> + </div> + ); +} diff --git a/apps/web/lib/hooks/useImportSessions.ts b/apps/web/lib/hooks/useImportSessions.ts index 4d095c0b..2cc632ad 100644 --- a/apps/web/lib/hooks/useImportSessions.ts +++ b/apps/web/lib/hooks/useImportSessions.ts @@ -1,7 +1,12 @@ "use client"; import { toast } from "@/components/ui/sonner"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useTRPC } from "@karakeep/shared-react/trpc"; @@ -131,3 +136,16 @@ export function useResumeImportSession() { }), ); } + +export function useImportSessionResults( + importSessionId: string, + filter: "all" | "accepted" | "rejected" | "skipped_duplicate" | "pending", +) { + const api = useTRPC(); + return useInfiniteQuery( + api.importSessions.getImportSessionResults.infiniteQueryOptions( + { importSessionId, filter, limit: 50 }, + { getNextPageParam: (lastPage) => lastPage.nextCursor }, + ), + ); +} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 59d2098e..06cfd7a1 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -435,7 +435,31 @@ "delete_dialog_description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone. The bookmarks themselves will not be deleted.", "delete_session": "Delete Session", "pause_session": "Pause", - "resume_session": "Resume" + "resume_session": "Resume", + "view_details": "View Details", + "detail": { + "page_title": "Import Session Details", + "back_to_import": "Back to Import", + "filter_all": "All", + "filter_accepted": "Accepted", + "filter_rejected": "Rejected", + "filter_duplicates": "Duplicates", + "filter_pending": "Pending", + "table_title": "Title / URL", + "table_type": "Type", + "table_result": "Result", + "table_reason": "Reason", + "table_bookmark": "Bookmark", + "result_accepted": "Accepted", + "result_rejected": "Rejected", + "result_skipped_duplicate": "Duplicate", + "result_pending": "Pending", + "result_processing": "Processing", + "no_results": "No results found for this filter.", + "view_bookmark": "View Bookmark", + "load_more": "Load More", + "no_title": "No title" + } }, "backups": { "backups": "Backups", |
