diff --git a/bun.lock b/bun.lock index b7bf72b7..6206aceb 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "i18next-http-backend": "^3.0.2", "i18next-resources-to-backend": "^1.2.1", "instantsearch.js": "^4.79.2", + "isbot": "^5.1.37", "json-stringify-pretty-compact": "^4.0.0", "md5": "^2.3.0", "mixpanel-browser": "^2.69.1", @@ -1903,6 +1904,8 @@ "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isbot": ["isbot@5.1.37", "", {}, "sha512-5bcicX81xf6NlTEV8rWdg7Pk01LFizDetuYGHx6d/f6y3lR2/oo8IfxjzJqn1UdDEyCcwT9e7NRloj8DwCYujQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], diff --git a/components/nodes/ContentToggle.tsx b/components/nodes/ContentToggle.tsx new file mode 100644 index 00000000..deb8e1c9 --- /dev/null +++ b/components/nodes/ContentToggle.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { useNextTranslation } from "@/src/hooks/i18n"; +import type { TranslationSource } from "@/src/hooks/i18n/translateNode"; + +const STORAGE_KEY = "comfy-registry-content-translation-mode"; +type Mode = "translated" | "original"; + +interface ContentToggleProps { + original: string; + translated: string | null; + locale: string; + isLoadingTranslation?: boolean; + /** + * Where the translation came from. `"stored"` means a publisher-provided + * translation from the registry API; `"auto"` means OpenAI generated it on + * the fly. Used to show an accurate label (AI-translated vs. human-translated) + * so users aren't misled about provenance. + */ + translationSource?: TranslationSource; +} + +/** + * Renders dynamic node content (description, etc.) with a toggle that lets + * users switch between the auto-translated version and the original English. + * + * - When `locale === "en"` or no translation is available, falls back to + * the original and hides the toggle entirely. + * - The user's preference is persisted in localStorage so toggling once + * sticks across navigations. + * - Default mode is "translated" when a translation exists, so users on + * non-English locales get the localized text by default. + */ +export default function ContentToggle({ + original, + translated, + locale, + isLoadingTranslation, + translationSource, +}: ContentToggleProps) { + const { t } = useNextTranslation(); + const [mode, setMode] = useState("translated"); + + // Restore stored preference on mount + useEffect(() => { + if (typeof window === "undefined") return; + const saved = window.localStorage.getItem(STORAGE_KEY); + if (saved === "original" || saved === "translated") setMode(saved); + }, []); + + const isNonEn = locale !== "en"; + const hasTranslation = !!translated && isNonEn; + const showOriginal = !hasTranslation || mode === "original"; + const displayed = showOriginal ? original : translated!; + // Show the header whenever we're on a non-en locale and either have a + // translation to toggle to or are still fetching one. This makes the + // "Translating…" indicator reachable while the async fetch is in flight. + const showHeader = isNonEn && (hasTranslation || isLoadingTranslation); + + const toggle = () => { + const next: Mode = mode === "translated" ? "original" : "translated"; + setMode(next); + if (typeof window !== "undefined") window.localStorage.setItem(STORAGE_KEY, next); + }; + + return ( +
+ {showHeader && ( +
+ {hasTranslation && ( + + )} + {hasTranslation && + !showOriginal && + (translationSource === "auto" ? ( + + {t("Auto-translated")} + + ) : translationSource === "stored" ? ( + {t("Translated")} + ) : ( + {t("Translated")} + ))} + {isLoadingTranslation && !translated && ( + {t("Translating…")} + )} +
+ )} +

{displayed}

+
+ ); +} diff --git a/components/nodes/NodeDetails.tsx b/components/nodes/NodeDetails.tsx index 5174960f..13599333 100644 --- a/components/nodes/NodeDetails.tsx +++ b/components/nodes/NodeDetails.tsx @@ -24,7 +24,9 @@ import { } from "@/src/api/generated"; import nodesLogo from "@/src/assets/images/nodesLogo.svg"; import { useNextTranslation } from "@/src/hooks/i18n"; +import type { TranslatedNodeContent } from "@/src/hooks/i18n/translateNode"; import CopyableCodeBlock from "../CodeBlock/CodeBlock"; +import ContentToggle from "./ContentToggle"; import { NodeDeleteModal } from "./NodeDeleteModal"; import { NodeEditModal } from "./NodeEditModal"; import NodeStatusBadge from "./NodeStatusBadge"; @@ -87,8 +89,13 @@ export function formatDownloadCount(count: number): string { return `${cleanNum}${units[unitIndex]}`; } -const NodeDetails = () => { +const NodeDetails = ({ + translatedContent, +}: { + translatedContent?: TranslatedNodeContent | null; +}) => { const { t, i18n } = useNextTranslation(); + // state for drawer and modals const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [selectedVersion, setSelectedVersion] = useState(null); @@ -489,7 +496,25 @@ const NodeDetails = () => {

{t("Description")}

-

{node.description}

+ {(() => { + const isrTranslation = + translatedContent?.locale === i18n.language && + translatedContent?.description && + translatedContent.description !== node.description + ? { + description: translatedContent.description, + source: translatedContent.source, + } + : null; + return ( + + ); + })()}