aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-10-04 13:40:24 +0100
committerGitHub <noreply@github.com>2025-10-04 13:40:24 +0100
commit4a580d713621f99abb8baabc9b847ce039d44842 (patch)
treea2aa6f3ae8045ad50a9316624e2a7028dd098c6b /apps/web/components
parent5e331a7d5b8d9666812170547574804d8b6da741 (diff)
downloadkarakeep-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/web/components')
-rw-r--r--apps/web/components/settings/ImportExport.tsx21
-rw-r--r--apps/web/components/settings/ImportSessionCard.tsx257
-rw-r--r--apps/web/components/settings/ImportSessionsSection.tsx79
3 files changed, 352 insertions, 5 deletions
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>
+ );
+}