Files
Netcatty/components/ConnectionLogsManager.tsx
T
LAPTOP-O016UC3M\Qi Chen d8347582b4 Adds full UI internationalization and language switching
Implements comprehensive i18n support, replacing hardcoded UI strings with translation keys and introducing an i18n provider across the app. Adds persistent UI language selection, cross-window sync, and system menu localization. Updates settings and state to handle locale, propagates language changes to Electron, and adjusts components for runtime translation.

Enables a user-friendly, multi-language experience and prepares the app for further language expansion.
2025-12-17 12:06:28 +08:00

244 lines
8.8 KiB
TypeScript

import {
Bookmark,
ChevronDown,
Server,
Terminal,
Trash2,
User,
} from "lucide-react";
import React, { memo, useCallback, useMemo } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { ConnectionLog, Host } from "../types";
import { ScrollArea } from "./ui/scroll-area";
interface ConnectionLogsManagerProps {
logs: ConnectionLog[];
hosts: Host[];
onToggleSaved: (id: string) => void;
onDelete: (id: string) => void;
onClearUnsaved: () => void;
onOpenLogView: (log: ConnectionLog) => void;
}
// Format date for display
const formatDate = (timestamp: number, locale: string) => {
const date = new Date(timestamp);
return date.toLocaleDateString(locale || undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
};
// Format time range
const formatTimeRange = (start: number, end: number | undefined, locale: string, ongoingLabel: string) => {
const startDate = new Date(start);
const startTime = startDate.toLocaleTimeString(locale || undefined, {
hour: "2-digit",
minute: "2-digit",
});
if (!end) {
return `${startTime} - ${ongoingLabel}`;
}
const endDate = new Date(end);
const endTime = endDate.toLocaleTimeString(locale || undefined, {
hour: "2-digit",
minute: "2-digit",
});
return `${startTime} - ${endTime}`;
};
// Log Item Component
interface LogItemProps {
log: ConnectionLog;
onToggleSaved: (id: string) => void;
onDelete: (id: string) => void;
onClick: () => void;
}
const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) => {
const { t, resolvedLocale } = useI18n();
const isLocal = log.protocol === "local" || log.hostname === "localhost";
return (
<div
className="group flex items-center gap-4 px-4 py-3 hover:bg-secondary/60 transition-colors border-b border-border/30 last:border-b-0 cursor-pointer"
onClick={onClick}
>
{/* Date column */}
<div className="w-32 shrink-0">
<div className="text-sm font-medium">{formatDate(log.startTime, resolvedLocale)}</div>
<div className="text-xs text-muted-foreground">
{formatTimeRange(log.startTime, log.endTime, resolvedLocale, t("logs.ongoing"))}
</div>
</div>
{/* User column */}
<div className="flex items-center gap-2 w-56 shrink-0">
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center shrink-0">
<User size={14} />
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{log.localUsername}</div>
<div className="text-xs text-muted-foreground truncate">{log.localHostname}</div>
</div>
</div>
{/* Host column */}
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
)}>
{isLocal ? <Terminal size={14} /> : <Server size={14} />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
<div className="text-xs text-muted-foreground truncate">
{isLocal ? "local" : `${log.protocol}, ${log.username}`}
</div>
</div>
</div>
{/* Saved column */}
<div className="flex items-center gap-2 shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onToggleSaved(log.id);
}}
className={cn(
"p-1.5 rounded-md transition-colors",
log.saved
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
)}
title={log.saved ? t("logs.action.unsave") : t("logs.action.save")}
>
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(log.id);
}}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
title={t("logs.action.delete")}
>
<Trash2 size={16} />
</button>
</div>
</div>
);
});
LogItem.displayName = "LogItem";
const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
logs,
hosts: _hosts,
onToggleSaved,
onDelete,
onClearUnsaved: _onClearUnsaved,
onOpenLogView,
}) => {
const { t } = useI18n();
const RENDER_LIMIT = 100;
// Sort logs by newest first
const filteredLogs = useMemo(() => {
return [...logs].sort((a, b) => b.startTime - a.startTime);
}, [logs]);
const displayedLogs = useMemo(() => {
return filteredLogs.slice(0, RENDER_LIMIT);
}, [filteredLogs]);
const hasMore = filteredLogs.length > RENDER_LIMIT;
const handleToggleSaved = useCallback(
(id: string) => onToggleSaved(id),
[onToggleSaved],
);
const handleDelete = useCallback(
(id: string) => onDelete(id),
[onDelete],
);
// Rendered items
const renderedItems = useMemo(() => {
return displayedLogs.map((log) => (
<LogItem
key={log.id}
log={log}
onToggleSaved={handleToggleSaved}
onDelete={handleDelete}
onClick={() => onOpenLogView(log)}
/>
));
}, [displayedLogs, handleToggleSaved, handleDelete, onOpenLogView]);
return (
<div className="h-full flex flex-col">
{/* Table Header */}
{displayedLogs.length > 0 && (
<div className="flex items-center gap-4 px-4 py-2 text-xs font-medium text-muted-foreground border-b border-border/30 bg-secondary/30">
<div className="w-32 shrink-0 flex items-center gap-1">
{t("logs.table.date")}
<ChevronDown size={12} />
</div>
<div className="w-56 shrink-0">{t("logs.table.user")}</div>
<div className="flex-1">{t("logs.table.host")}</div>
<div className="w-20 shrink-0 flex items-center gap-1">
{t("logs.table.saved")}
<Bookmark size={12} />
</div>
</div>
)}
{/* Content */}
<ScrollArea className="flex-1">
<div>
{displayedLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Terminal size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{t("logs.empty.title")}
</h3>
<p className="text-sm text-center max-w-sm">
{t("logs.empty.desc")}
</p>
</div>
) : (
<>
{renderedItems}
{hasMore && (
<div className="text-center py-4 text-sm text-muted-foreground">
{t("logs.showing", { limit: RENDER_LIMIT, total: filteredLogs.length })}
</div>
)}
</>
)}
</div>
</ScrollArea>
</div>
);
};
// Custom comparison
const logsManagerAreEqual = (
prev: ConnectionLogsManagerProps,
next: ConnectionLogsManagerProps,
): boolean => {
return prev.logs === next.logs && prev.hosts === next.hosts;
};
export default memo(ConnectionLogsManager, logsManagerAreEqual);