diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-10-04 13:40:24 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-10-04 13:40:24 +0100 |
| commit | 4a580d713621f99abb8baabc9b847ce039d44842 (patch) | |
| tree | a2aa6f3ae8045ad50a9316624e2a7028dd098c6b /apps | |
| parent | 5e331a7d5b8d9666812170547574804d8b6da741 (diff) | |
| download | karakeep-4a580d713621f99abb8baabc9b847ce039d44842.tar.zst | |
feat: Revamp import experience (#2001)
* WIP: import v2
* remove new session button
* don't redirect after import
* store and lint to root list
* models + tests
* redesign the progress
* simplify the import session for ow
* drop status from session schema
* split the import session page
* i18n
* fix test
* remove pagination
* fix some colors in darkmode
* one last fix
* add privacy filter
* privacy check
* fix interactivity of import progress
* fix test
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/app/settings/import/page.tsx | 6 | ||||
| -rw-r--r-- | apps/web/components/settings/ImportExport.tsx | 21 | ||||
| -rw-r--r-- | apps/web/components/settings/ImportSessionCard.tsx | 257 | ||||
| -rw-r--r-- | apps/web/components/settings/ImportSessionsSection.tsx | 79 | ||||
| -rw-r--r-- | apps/web/lib/hooks/useBookmarkImport.ts | 21 | ||||
| -rw-r--r-- | apps/web/lib/hooks/useImportSessions.ts | 62 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 27 |
7 files changed, 455 insertions, 18 deletions
diff --git a/apps/web/app/settings/import/page.tsx b/apps/web/app/settings/import/page.tsx index e27aa9a8..11780d51 100644 --- a/apps/web/app/settings/import/page.tsx +++ b/apps/web/app/settings/import/page.tsx @@ -1,9 +1,5 @@ import ImportExport from "@/components/settings/ImportExport"; export default function ImportSettingsPage() { - return ( - <div className="rounded-md border bg-background p-4"> - <ImportExport /> - </div> - ); + return <ImportExport />; } diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx index c644af27..ee220342 100644 --- a/apps/web/components/settings/ImportExport.tsx +++ b/apps/web/components/settings/ImportExport.tsx @@ -19,6 +19,7 @@ import { Download, Loader2, Upload } from "lucide-react"; import { Card, CardContent } from "../ui/card"; import { toast } from "../ui/use-toast"; +import { ImportSessionsSection } from "./ImportSessionsSection"; function ImportCard({ text, @@ -266,11 +267,21 @@ export function ImportExportRow() { export default function ImportExport() { const { t } = useTranslation(); return ( - <div className="flex w-full flex-col gap-2"> - <p className="mb-4 text-lg font-medium"> - {t("settings.import.import_export_bookmarks")} - </p> - <ImportExportRow /> + <div className="space-y-3"> + <div className="rounded-md border bg-background p-4"> + <div className="flex w-full flex-col gap-6"> + <div> + <p className="mb-4 text-lg font-medium"> + {t("settings.import.import_export_bookmarks")} + </p> + <ImportExportRow /> + </div> + </div> + </div> + + <div className="rounded-md border bg-background p-4"> + <ImportSessionsSection /> + </div> </div> ); } diff --git a/apps/web/components/settings/ImportSessionCard.tsx b/apps/web/components/settings/ImportSessionCard.tsx new file mode 100644 index 00000000..690caaa5 --- /dev/null +++ b/apps/web/components/settings/ImportSessionCard.tsx @@ -0,0 +1,257 @@ +"use client"; + +import Link from "next/link"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { + useDeleteImportSession, + useImportSessionStats, +} from "@/lib/hooks/useImportSessions"; +import { useTranslation } from "@/lib/i18n/client"; +import { formatDistanceToNow } from "date-fns"; +import { + AlertCircle, + CheckCircle2, + ClipboardList, + Clock, + ExternalLink, + Loader2, + Trash2, +} from "lucide-react"; + +import type { ZImportSessionWithStats } from "@karakeep/shared/types/importSessions"; + +interface ImportSessionCardProps { + session: ZImportSessionWithStats; +} + +function getStatusColor(status: string) { + switch (status) { + case "pending": + return "bg-muted text-muted-foreground"; + case "in_progress": + return "bg-blue-500/10 text-blue-700 dark:text-blue-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 "pending": + return <Clock className="h-4 w-4" />; + case "in_progress": + return <Loader2 className="h-4 w-4 animate-spin" />; + 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" />; + } +} + +export function ImportSessionCard({ session }: ImportSessionCardProps) { + const { t } = useTranslation(); + const { data: liveStats } = useImportSessionStats(session.id); + const deleteSession = useDeleteImportSession(); + + const statusLabels: Record<string, string> = { + pending: t("settings.import_sessions.status.pending"), + in_progress: t("settings.import_sessions.status.in_progress"), + completed: t("settings.import_sessions.status.completed"), + failed: t("settings.import_sessions.status.failed"), + }; + + // Use live stats if available, otherwise fallback to session stats + const stats = liveStats || session; + const progress = + stats.totalBookmarks > 0 + ? ((stats.completedBookmarks + stats.failedBookmarks) / + stats.totalBookmarks) * + 100 + : 0; + + const canDelete = stats.status !== "in_progress"; + + return ( + <Card className="transition-all hover:shadow-md"> + <CardHeader className="pb-3"> + <div className="flex items-start justify-between"> + <div className="flex-1"> + <h3 className="font-medium">{session.name}</h3> + <p className="mt-1 text-sm text-accent-foreground"> + {t("settings.import_sessions.created_at", { + time: formatDistanceToNow(session.createdAt, { + addSuffix: true, + }), + })} + </p> + </div> + <div className="flex items-center gap-2"> + <Badge + className={`${getStatusColor(stats.status)} hover:bg-inherit`} + > + {getStatusIcon(stats.status)} + <span className="ml-1 capitalize"> + {statusLabels[stats.status] ?? stats.status.replace("_", " ")} + </span> + </Badge> + </div> + </div> + </CardHeader> + + <CardContent className="pt-0"> + <div className="space-y-3"> + {/* Progress Section */} + <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> + {stats.totalBookmarks > 0 && ( + <Progress value={progress} className="h-3" /> + )} + </div> + + {/* Stats Breakdown */} + {stats.totalBookmarks > 0 && ( + <div className="space-y-3"> + <div className="flex flex-wrap gap-2"> + {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> + )} + {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> + )} + </div> + </div> + )} + + {/* Root List Link */} + {session.rootListId && ( + <div className="rounded-lg border bg-muted/50 p-3 dark:bg-muted/20"> + <div className="flex items-center gap-2 text-sm"> + <ClipboardList className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium text-muted-foreground"> + {t("settings.import_sessions.imported_to")} + </span> + <Link + href={`/dashboard/lists/${session.rootListId}`} + className="flex items-center gap-1 font-medium text-primary transition-colors hover:text-primary/80" + target="_blank" + > + {t("settings.import_sessions.view_list")} + <ExternalLink className="h-3 w-3" /> + </Link> + </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> + )} + + {/* Actions */} + <div className="flex items-center justify-end pt-2"> + <div className="flex items-center gap-2"> + {canDelete && ( + <ActionConfirmingDialog + title={t("settings.import_sessions.delete_dialog_title")} + description={ + <div> + {t("settings.import_sessions.delete_dialog_description", { + name: session.name, + })} + </div> + } + actionButton={(setDialogOpen) => ( + <Button + variant="destructive" + onClick={() => { + deleteSession.mutateAsync({ + importSessionId: session.id, + }); + 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> + </CardContent> + </Card> + ); +} diff --git a/apps/web/components/settings/ImportSessionsSection.tsx b/apps/web/components/settings/ImportSessionsSection.tsx new file mode 100644 index 00000000..38f248d2 --- /dev/null +++ b/apps/web/components/settings/ImportSessionsSection.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import { useListImportSessions } from "@/lib/hooks/useImportSessions"; +import { useTranslation } from "@/lib/i18n/client"; +import { Package } from "lucide-react"; + +import { FullPageSpinner } from "../ui/full-page-spinner"; +import { ImportSessionCard } from "./ImportSessionCard"; + +export function ImportSessionsSection() { + const { t } = useTranslation(); + const { data: sessions, isLoading, error } = useListImportSessions(); + + if (isLoading) { + return ( + <div className="flex w-full flex-col gap-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-medium"> + {t("settings.import_sessions.title")} + </h3> + </div> + <FullPageSpinner /> + </div> + ); + } + + if (error) { + return ( + <div className="flex w-full flex-col gap-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-medium"> + {t("settings.import_sessions.title")} + </h3> + </div> + <Card> + <CardContent className="flex items-center justify-center py-8"> + <p className="text-gray-600"> + {t("settings.import_sessions.load_error")} + </p> + </CardContent> + </Card> + </div> + ); + } + + return ( + <div className="flex w-full flex-col gap-4"> + <div> + <h3 className="text-lg font-medium"> + {t("settings.import_sessions.title")} + </h3> + <p className="mt-1 text-sm text-accent-foreground"> + {t("settings.import_sessions.description")} + </p> + </div> + + {sessions && sessions.length > 0 ? ( + <div className="space-y-4"> + {sessions.map((session) => ( + <ImportSessionCard key={session.id} session={session} /> + ))} + </div> + ) : ( + <Card> + <CardContent className="flex flex-col items-center justify-center py-12"> + <Package className="mb-4 h-12 w-12 text-gray-400" /> + <p className="mb-2 text-center text-gray-600"> + {t("settings.import_sessions.no_sessions")} + </p> + <p className="text-center text-sm text-gray-500"> + {t("settings.import_sessions.no_sessions_detail")} + </p> + </CardContent> + </Card> + )} + </div> + ); +} diff --git a/apps/web/lib/hooks/useBookmarkImport.ts b/apps/web/lib/hooks/useBookmarkImport.ts index de515677..a4ebdd9c 100644 --- a/apps/web/lib/hooks/useBookmarkImport.ts +++ b/apps/web/lib/hooks/useBookmarkImport.ts @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { useMutation } from "@tanstack/react-query"; @@ -24,6 +23,8 @@ import { MAX_BOOKMARK_TITLE_LENGTH, } from "@karakeep/shared/types/bookmarks"; +import { useCreateImportSession } from "./useImportSessions"; + export interface ImportProgress { done: number; total: number; @@ -31,12 +32,12 @@ export interface ImportProgress { export function useBookmarkImport() { const { t } = useTranslation(); - const router = useRouter(); const [importProgress, setImportProgress] = useState<ImportProgress | null>( null, ); + const { mutateAsync: createImportSession } = useCreateImportSession(); const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook(); const { mutateAsync: createList } = useCreateBookmarkList(); const { mutateAsync: addToList } = useAddBookmarkToList(); @@ -56,8 +57,12 @@ export function useBookmarkImport() { source, rootListName: t("settings.import.imported_bookmarks"), deps: { - createList: createList, - createBookmark: async (bookmark: ParsedBookmark) => { + createImportSession, + createList, + createBookmark: async ( + bookmark: ParsedBookmark, + sessionId: string, + ) => { if (bookmark.content === undefined) { throw new Error("Content is undefined"); } @@ -69,6 +74,7 @@ export function useBookmarkImport() { : undefined, note: bookmark.notes, archived: bookmark.archived, + importSessionId: sessionId, ...(bookmark.content.type === BookmarkTypes.LINK ? { type: BookmarkTypes.LINK, @@ -120,6 +126,8 @@ export function useBookmarkImport() { return result; }, onSuccess: async (result) => { + setImportProgress(null); + if (result.counts.total === 0) { toast({ description: "No bookmarks found in the file." }); return; @@ -127,7 +135,7 @@ export function useBookmarkImport() { const { successes, failures, alreadyExisted } = result.counts; if (successes > 0 || alreadyExisted > 0) { toast({ - description: `Imported ${successes} bookmarks and skipped ${alreadyExisted} bookmarks that already existed`, + description: `Imported ${successes} bookmarks into import session. Background processing will start automatically.`, variant: "default", }); } @@ -137,9 +145,6 @@ export function useBookmarkImport() { variant: "destructive", }); } - - if (result.rootListId) - router.push(`/dashboard/lists/${result.rootListId}`); }, onError: (error) => { setImportProgress(null); diff --git a/apps/web/lib/hooks/useImportSessions.ts b/apps/web/lib/hooks/useImportSessions.ts new file mode 100644 index 00000000..cee99bbc --- /dev/null +++ b/apps/web/lib/hooks/useImportSessions.ts @@ -0,0 +1,62 @@ +"use client"; + +import { toast } from "@/components/ui/use-toast"; + +import { api } from "@karakeep/shared-react/trpc"; + +export function useCreateImportSession() { + const apiUtils = api.useUtils(); + + return api.importSessions.createImportSession.useMutation({ + onSuccess: () => { + apiUtils.importSessions.listImportSessions.invalidate(); + }, + onError: (error) => { + toast({ + description: error.message || "Failed to create import session", + variant: "destructive", + }); + }, + }); +} + +export function useListImportSessions() { + return api.importSessions.listImportSessions.useQuery( + {}, + { + select: (data) => data.sessions, + }, + ); +} + +export function useImportSessionStats(importSessionId: string) { + return api.importSessions.getImportSessionStats.useQuery( + { + importSessionId, + }, + { + refetchInterval: 5000, // Refetch every 5 seconds to show progress + enabled: !!importSessionId, + }, + ); +} + +export function useDeleteImportSession() { + const apiUtils = api.useUtils(); + + return api.importSessions.deleteImportSession.useMutation({ + onSuccess: () => { + apiUtils.importSessions.listImportSessions.invalidate(); + toast({ + description: "Import session deleted successfully", + variant: "default", + }); + }, + onError: (error) => { + toast({ + description: error.message || "Failed to delete import session", + variant: "destructive", + }); + }, + }); +} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index ab1306be..450949c6 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -314,6 +314,33 @@ "loading_usage": "Loading usage information...", "free": "Free", "paid": "Paid" + }, + "import_sessions": { + "title": "Import Sessions", + "description": "View and manage your bulk import sessions. Sessions are automatically created when you import bookmarks.", + "load_error": "Failed to load import sessions", + "no_sessions": "No import sessions yet", + "no_sessions_detail": "Import sessions will appear here automatically when you import bookmarks", + "created_at": "Created {{time}}", + "progress": "Progress", + "status": { + "pending": "Pending", + "in_progress": "In progress", + "completed": "Completed", + "failed": "Failed", + "processing": "Processing" + }, + "badges": { + "pending": "{{count}} pending", + "processing": "{{count}} processing", + "completed": "{{count}} completed", + "failed": "{{count}} failed" + }, + "imported_to": "Imported to:", + "view_list": "View List", + "delete_dialog_title": "Delete Import Session", + "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" } }, "admin": { |
