From 5274fe32d385969ea6a05830efda189fc6c08217 Mon Sep 17 00:00:00 2001 From: snomiao Date: Sat, 26 Jul 2025 16:22:55 +0000 Subject: [PATCH 01/15] feat: add experimental browser translation support for node metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing key handler with experimental Translator API integration - Translate node descriptions using t() function for internationalization - Format code improvements for better readability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- components/nodes/NodeDetails.tsx | 2 +- src/hooks/i18n/index.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/components/nodes/NodeDetails.tsx b/components/nodes/NodeDetails.tsx index 0591d425..26bae420 100644 --- a/components/nodes/NodeDetails.tsx +++ b/components/nodes/NodeDetails.tsx @@ -477,7 +477,7 @@ const NodeDetails = () => { {t('Description')}

- {node.description} + {node.description && t(node.description)}

diff --git a/src/hooks/i18n/index.tsx b/src/hooks/i18n/index.tsx index 37badb70..3b1afaaa 100644 --- a/src/hooks/i18n/index.tsx +++ b/src/hooks/i18n/index.tsx @@ -121,23 +121,6 @@ export const useDynamicTranslate = () => { return { available, enabled, setEnabled, dt } } -export const DynamicTranslateSwitcher = () => { - const { available, enabled, setEnabled } = useDynamicTranslate() - if (!available) return null - return ( - { - setEnabled(!enabled) // toggle the translation statey - }} - > - { - enabled ? '🔄' : '🌐' // use a different icon to indicate translation is disabled - } - - ) -} /** * Custom hook for translations in the Comfy Registry * @param namespace - The namespace to use for translations (defaults to 'common') From 51da17be48d0f921c1ea3942f99ea1ae48c1b66f Mon Sep 17 00:00:00 2001 From: snomiao Date: Thu, 2 Oct 2025 14:36:38 +0000 Subject: [PATCH 09/15] feat: add proper TypeScript types for Translator API - Define TranslatorAPI and TranslatorInstance interfaces - Replace 'any' types with proper type definitions - Add global type declarations for Chrome's experimental API - Improve type safety and developer experience --- src/hooks/i18n/index.tsx | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/hooks/i18n/index.tsx b/src/hooks/i18n/index.tsx index 3b1afaaa..a0f7305c 100644 --- a/src/hooks/i18n/index.tsx +++ b/src/hooks/i18n/index.tsx @@ -15,6 +15,25 @@ import useCookieValue from 'react-use-cookie' import { useAsyncData } from 'use-async' import { useLocalStorage } from 'react-use' +// Type definitions for Chrome's experimental Translator API +interface TranslatorAPI { + create(options: { + sourceLanguage: string + targetLanguage: string + }): Promise +} + +interface TranslatorInstance { + translateStreaming(text: string): AsyncIterable +} + +declare global { + interface Window { + Translator?: TranslatorAPI + } + var Translator: TranslatorAPI | undefined +} + const i18n = i18next .use(I18nextBrowserLanguageDetector) .use( @@ -57,7 +76,7 @@ i18n.init({ if (typeof globalThis.Translator === 'undefined') return // Create a translator instance - const Translator = globalThis.Translator as any + const Translator = globalThis.Translator as TranslatorAPI const translator = await Translator.create({ sourceLanguage: 'en', targetLanguage: lng, @@ -100,7 +119,8 @@ export const useDynamicTranslate = () => { // 3. not available in china // const [available, availableState] = useAsyncData(async () => { - const Translator = globalThis.Translator as any + if (typeof globalThis.Translator === 'undefined') return null + const Translator = globalThis.Translator as TranslatorAPI const translator = await Translator.create({ sourceLanguage: 'en', targetLanguage: currentLanguage, From d82aa5befd9c412ab7513d2ebaae39a462bb2787 Mon Sep 17 00:00:00 2001 From: snomiao Date: Fri, 17 Oct 2025 00:17:00 +0000 Subject: [PATCH 10/15] feat: add experimental browser-based dynamic translation for node metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR implements an optional dynamic translation feature using Chrome's built-in Translator API to translate node descriptions and changelogs on-demand. ## Changes - Add `useDynamicTranslate()` hook with browser Translator API integration - Add dynamic translation toggle in language dropdown - Apply `dt()` function to node descriptions and version changelogs - Add "Dynamic Translation" key to all locale files - Add comprehensive documentation in docs/node-metadata-translation.md ## Key Features - Browser-based, local translation (no server costs) - Opt-in via toggle in language dropdown - Only translates user-generated content (descriptions, changelogs) - Falls back gracefully when API unavailable - Works offline after initial model download ## Technical Details - Uses Chrome 138+ experimental Translator API - LocalStorage persistence for user preference - i18next integration with missing key handler - Automatic re-rendering on translation completion ## Limitations - Chrome 138+ only - Not available in all regions - Client-side only (no SSR support) - Requires user to enable manually 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- bun.lock | 3 + components/common/LanguageSwitcher.tsx | 37 ++++++- components/nodes/NodeDetails.tsx | 8 +- docs/node-metadata-translation.md | 124 ++++++++++++++++++++++++ locales/en/common.json | 1 + package.json | 1 + src/hooks/i18n/index.ts | 128 ++++++++++++++++++++++--- 7 files changed, 284 insertions(+), 18 deletions(-) create mode 100644 docs/node-metadata-translation.md diff --git a/bun.lock b/bun.lock index e0137f50..4d879ccc 100644 --- a/bun.lock +++ b/bun.lock @@ -70,6 +70,7 @@ "sflow": "^1.24.5", "sharp": "^0.34.3", "styled-jsx": "^5.1.7", + "use-async": "^1.2.0", "yaml": "^2.8.1", "zod": "^3.25.76", }, @@ -2758,6 +2759,8 @@ "urijs": ["urijs@1.19.11", "", {}, "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="], + "use-async": ["use-async@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-gG7lA2PiqYpBIcdRN6p7NaSOsq/Rt6rNkf7e1y6e+7yvfVnc2YkDzVbkQbDQ2z+teEbhracinIB1DZIQRm8zHA=="], + "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], diff --git a/components/common/LanguageSwitcher.tsx b/components/common/LanguageSwitcher.tsx index a754d3bf..2232af17 100644 --- a/components/common/LanguageSwitcher.tsx +++ b/components/common/LanguageSwitcher.tsx @@ -4,7 +4,7 @@ import Link, { LinkProps } from 'next/link' import { useRouter } from 'next/router' import React, { useEffect, useMemo, useState } from 'react' import { SUPPORTED_LANGUAGES } from '@/src/constants' -import { useNextTranslation } from '@/src/hooks/i18n' +import { useDynamicTranslate, useNextTranslation } from '@/src/hooks/i18n' import { LocalizationContributeModal } from './LocalizationContributeModal' export default function LanguageSwitcher({ @@ -13,6 +13,11 @@ export default function LanguageSwitcher({ className?: string } = {}) { const { t, i18n, changeLanguage, currentLanguage } = useNextTranslation() + const { + available: dynamicTranslateAvailable, + enabled: dynamicTranslateEnabled, + setEnabled: setDynamicTranslateEnabled, + } = useDynamicTranslate() const router = useRouter() const [showContributeModal, setShowContributeModal] = useState(false) @@ -148,6 +153,36 @@ export default function LanguageSwitcher({ ) })} + {dynamicTranslateAvailable && ( + { + e.preventDefault() + setDynamicTranslateEnabled(!dynamicTranslateEnabled) + }} + > +
+
+ 🔄 + {t('Dynamic Translation')} + + (Beta) + +
+ + setDynamicTranslateEnabled( + !dynamicTranslateEnabled + ) + } + className="form-checkbox h-4 w-4 text-blue-600" + onClick={(e) => e.stopPropagation()} + /> +
+
+ )} setShowContributeModal(true)} diff --git a/components/nodes/NodeDetails.tsx b/components/nodes/NodeDetails.tsx index 46a040ee..327282f8 100644 --- a/components/nodes/NodeDetails.tsx +++ b/components/nodes/NodeDetails.tsx @@ -22,7 +22,7 @@ import { useListPublishersForUser, } from '@/src/api/generated' import nodesLogo from '@/src/assets/images/nodesLogo.svg' -import { useNextTranslation } from '@/src/hooks/i18n' +import { useDynamicTranslate, useNextTranslation } from '@/src/hooks/i18n' import CopyableCodeBlock from '../CodeBlock/CodeBlock' import { NodeDeleteModal } from './NodeDeleteModal' import { NodeEditModal } from './NodeEditModal' @@ -90,6 +90,8 @@ export function formatDownloadCount(count: number): string { const NodeDetails = () => { const { t, i18n } = useNextTranslation() + const { dt } = useDynamicTranslate() + // state for drawer and modals const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [selectedVersion, setSelectedVersion] = useState( @@ -477,7 +479,7 @@ const NodeDetails = () => { {t('Description')}

- {node.description} + {dt(node.description)}

+ } + > + + + {firebaseUser.displayName || t('User')} + + + {firebaseUser.email} + + + router.push('/nodes')}> + {t('Your Nodes')} + + {user?.isAdmin && ( + router.push('/admin')}> + {t('Admin Dashboard')} + + )} + + {t('Logout')} + + ) } diff --git a/components/Labels/ClearableLabel.stories.tsx b/components/Labels/ClearableLabel.stories.tsx index 3f857350..8bb678c1 100644 --- a/components/Labels/ClearableLabel.stories.tsx +++ b/components/Labels/ClearableLabel.stories.tsx @@ -4,54 +4,54 @@ import { ClearableLabel } from '@/components/Labels/ClearableLabel' // We need to use a functional component for this because it has state const ClearableLabelWithState = ({ - label, - initialValue = '', - disabled = false, + label, + initialValue = '', + disabled = false, }) => { - const [value, setValue] = useState(initialValue) + const [value, setValue] = useState(initialValue) - return ( - setValue('')} - disabled={disabled} - /> - ) + return ( + setValue('')} + disabled={disabled} + /> + ) } const meta: Meta = { - title: 'Components/Labels/ClearableLabel', - component: ClearableLabelWithState, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], + title: 'Components/Labels/ClearableLabel', + component: ClearableLabelWithState, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], } export default meta type Story = StoryObj export const Empty: Story = { - args: { - label: 'Search', - initialValue: '', - }, + args: { + label: 'Search', + initialValue: '', + }, } export const WithValue: Story = { - args: { - label: 'Search', - initialValue: 'Text to clear', - }, + args: { + label: 'Search', + initialValue: 'Text to clear', + }, } export const Disabled: Story = { - args: { - label: 'Search (Disabled)', - initialValue: 'Cannot be cleared', - disabled: true, - }, + args: { + label: 'Search (Disabled)', + initialValue: 'Cannot be cleared', + disabled: true, + }, } diff --git a/components/Labels/ClearableLabel.tsx b/components/Labels/ClearableLabel.tsx index 65ebd632..178440ae 100644 --- a/components/Labels/ClearableLabel.tsx +++ b/components/Labels/ClearableLabel.tsx @@ -3,37 +3,37 @@ import React from 'react' import { MdClear } from 'react-icons/md' export const ClearableLabel: React.FC<{ - id: string - label: string - value: string - disabled?: boolean - onClear: () => void - onChange: (value: string) => void + id: string + label: string + value: string + disabled?: boolean + onClear: () => void + onChange: (value: string) => void }> = ({ label, value, onClear, onChange, id, disabled = false }) => { - return ( -
- onChange(e.target.value)} - disabled={disabled} - /> - {value && ( - - )} -
- ) + return ( +
+ onChange(e.target.value)} + disabled={disabled} + /> + {value && ( + + )} +
+ ) } diff --git a/components/MailtoNodeVersionModal.tsx b/components/MailtoNodeVersionModal.tsx index 9cd73ef8..16d35dc4 100644 --- a/components/MailtoNodeVersionModal.tsx +++ b/components/MailtoNodeVersionModal.tsx @@ -2,111 +2,103 @@ import { Button, Modal, Spinner } from 'flowbite-react' import Link from 'next/link' import { FaGithub } from 'react-icons/fa' import { - NodeVersion, - Publisher, - useGetNode, - useGetPublisher, + NodeVersion, + Publisher, + useGetNode, + useGetPublisher, } from '@/src/api/generated' import { useNextTranslation } from '@/src/hooks/i18n' export default function MailtoNodeVersionModal({ - nodeVersion: nv, - open, - onClose, + nodeVersion: nv, + open, + onClose, }: { - nodeVersion?: NodeVersion - open: boolean - onClose: () => void + nodeVersion?: NodeVersion + open: boolean + onClose: () => void }) { - const { t } = useNextTranslation() - // 1. repo+"/issues/new" for github issues - // 2. mailto:email for email - const { data: node, isLoading: isNodeLoading } = useGetNode( - nv?.node_id ?? '', - {}, - { query: { enabled: !!nv } } - ) - const { data: publisher, isLoading: publisherLoading } = useGetPublisher( - node?.id ?? '', - { query: { enabled: !!node?.id } } - ) + const { t } = useNextTranslation() + // 1. repo+"/issues/new" for github issues + // 2. mailto:email for email + const { data: node, isLoading: isNodeLoading } = useGetNode( + nv?.node_id ?? '', + {}, + { query: { enabled: !!nv } } + ) + const { data: publisher, isLoading: publisherLoading } = useGetPublisher( + node?.id ?? '', + { query: { enabled: !!node?.id } } + ) - const newIssueLink = !node?.repository - ? 'javascript:' - : `${node.repository}/issues/new?title=${encodeURIComponent(t('Issue with Node Version {{nodeId}}@{{version}}', { nodeId: nv?.node_id, version: nv?.version }))}&body=${encodeURIComponent(t('Node Version: {{nodeId}}@{{version}}\n\nPlease describe the issue or request you have regarding this node version.', { nodeId: nv?.node_id, version: nv?.version }))}` + const newIssueLink = !node?.repository + ? 'javascript:' + : `${node.repository}/issues/new?title=${encodeURIComponent(t('Issue with Node Version {{nodeId}}@{{version}}', { nodeId: nv?.node_id, version: nv?.version }))}&body=${encodeURIComponent(t('Node Version: {{nodeId}}@{{version}}\n\nPlease describe the issue or request you have regarding this node version.', { nodeId: nv?.node_id, version: nv?.version }))}` - if (!nv) return null + if (!nv) return null - return ( - - - {t('Contact Publisher: {{name}}', { - name: publisher?.name || publisher?.id, - })} - - -
-
    - {!!node?.repository && ( -
  1. -

    - {t( - 'You can contact the publisher via GitHub:' - )} -

    - - - {t('Open Issue on GitHub')} - {isNodeLoading && ( - - )} - -
  2. - )} - {publisher?.members?.length && ( -
  3. -

    - {t( - 'You can contact the publisher via email:' - )} -

    - - {publisherLoading && ( - - )} -
  4. - )} -
-
-
- - - -
- ) + return ( + + + {t('Contact Publisher: {{name}}', { + name: publisher?.name || publisher?.id, + })} + + +
+
    + {!!node?.repository && ( +
  1. +

    + {t('You can contact the publisher via GitHub:')} +

    + + + {t('Open Issue on GitHub')} + {isNodeLoading && } + +
  2. + )} + {publisher?.members?.length && ( +
  3. +

    + {t('You can contact the publisher via email:')} +

    + + {publisherLoading && } +
  4. + )} +
+
+
+ + + +
+ ) } function ListPublisherEmails({ publisher }: { publisher: Publisher }) { - return ( -
    - {publisher?.members - ?.map((member) => member.user?.email) - // type-safe filter to remove empty emails - ?.flatMap((e) => (e ? [e] : [])) - .map((email) => ( -
  • - - {email} - -
  • - ))} -
- ) + return ( +
    + {publisher?.members + ?.map((member) => member.user?.email) + // type-safe filter to remove empty emails + ?.flatMap((e) => (e ? [e] : [])) + .map((email) => ( +
  • + + {email} + +
  • + ))} +
+ ) } diff --git a/components/NodeStatusBadge.tsx b/components/NodeStatusBadge.tsx index 9603c89d..0f0f9d86 100644 --- a/components/NodeStatusBadge.tsx +++ b/components/NodeStatusBadge.tsx @@ -3,27 +3,27 @@ import { NodeVersionStatusToReadable } from 'src/mapper/nodeversion' import { NodeVersionStatus } from '@/src/api/generated' export function NodeStatusBadge({ - status, - count, + status, + count, }: { - status: NodeVersionStatus - count?: number + status: NodeVersionStatus + count?: number }) { - return ( - - {NodeVersionStatusToReadable({ - status: status as NodeVersionStatus, - })} - {count != null ? x{count} : null} - - ) + return ( + + {NodeVersionStatusToReadable({ + status: status as NodeVersionStatus, + })} + {count != null ? x{count} : null} + + ) } diff --git a/components/NodeStatusReason.tsx b/components/NodeStatusReason.tsx index ab21f1d5..4b9a054b 100644 --- a/components/NodeStatusReason.tsx +++ b/components/NodeStatusReason.tsx @@ -17,10 +17,10 @@ import { NodeVersionStatusToReadable } from 'src/mapper/nodeversion' import yaml from 'yaml' import { z } from 'zod' import { - NodeVersion, - NodeVersionStatus, - useGetNode, - useListNodeVersions, + NodeVersion, + NodeVersionStatus, + useGetNode, + useListNodeVersions, } from '@/src/api/generated' import { useNextTranslation } from '@/src/hooks/i18n' import { NodeStatusBadge } from './NodeStatusBadge' @@ -29,557 +29,516 @@ import { parseJsonSafe } from './parseJsonSafe' // schema reference from (private): https://github.com/Comfy-Org/security-scanner export const zErrorArray = z - .object({ - issue_type: z.string(), // The error type is represented as a string - file_path: z.string().optional(), // File name is a string and may or may not be present - line_number: z.number().optional(), // Line number can be a string or number and may or may not be present - code_snippet: z.string().optional(), // Line content where the error is found is a string and optional - message: z.string().optional(), // Line content where the error is found is a string and optional - scanner: z.string().optional(), // Scanner name is a string and optional - // yara - // meta: z - // .object({ - // description: z.string(), - // version: z.string(), - // date: z.string(), - // reference: z.string(), - // category: z.string(), - // observable_refs: z.string(), - // attack_id1: z.string(), - // attack_id2: z.string(), - // severity: z.string(), - // }) - // .passthrough() - // .optional(), // Meta information is optional and contains a detailed description if present - // yara - // matches: z - // .array( - // z - // .object({ - // filepath: z.string(), - // strings: z.array( - // z.object({ - // identifier: z.string(), - // instances: z.array( - // z.object({ - // matched_data: z.string(), - // matched_length: z.number(), - // offset: z.number(), - // line_number: z.number(), - // line: z.string(), - // }) - // ), - // }) - // ), - // }) - // .passthrough() - // .optional() - // ) - // .optional(), // Matches array, if present, contains detailed match information - }) - .passthrough() - .array() + .object({ + issue_type: z.string(), // The error type is represented as a string + file_path: z.string().optional(), // File name is a string and may or may not be present + line_number: z.number().optional(), // Line number can be a string or number and may or may not be present + code_snippet: z.string().optional(), // Line content where the error is found is a string and optional + message: z.string().optional(), // Line content where the error is found is a string and optional + scanner: z.string().optional(), // Scanner name is a string and optional + // yara + // meta: z + // .object({ + // description: z.string(), + // version: z.string(), + // date: z.string(), + // reference: z.string(), + // category: z.string(), + // observable_refs: z.string(), + // attack_id1: z.string(), + // attack_id2: z.string(), + // severity: z.string(), + // }) + // .passthrough() + // .optional(), // Meta information is optional and contains a detailed description if present + // yara + // matches: z + // .array( + // z + // .object({ + // filepath: z.string(), + // strings: z.array( + // z.object({ + // identifier: z.string(), + // instances: z.array( + // z.object({ + // matched_data: z.string(), + // matched_length: z.number(), + // offset: z.number(), + // line_number: z.number(), + // line: z.string(), + // }) + // ), + // }) + // ), + // }) + // .passthrough() + // .optional() + // ) + // .optional(), // Matches array, if present, contains detailed match information + }) + .passthrough() + .array() export const zStatusCode = z.enum( - Object.values(NodeVersionStatus) as [ - NodeVersionStatus, - ...NodeVersionStatus[], - ] + Object.values(NodeVersionStatus) as [ + NodeVersionStatus, + ...NodeVersionStatus[], + ] ) export const zStatusHistory = z.array( - z.object({ - status: zStatusCode, - message: z.string(), - by: z.string().optional(), - }) + z.object({ + status: zStatusCode, + message: z.string(), + by: z.string().optional(), + }) ) // when status is active/banned, the statusReason is approve/reject reason, and maybe a status history export const zStatusReason = z.object({ - message: z.string(), - by: z.string(), + message: z.string(), + by: z.string(), - // statusHistory, allow undo - statusHistory: zStatusHistory.optional(), + // statusHistory, allow undo + statusHistory: zStatusHistory.optional(), - // batchId for batch operations (for future batch-undo) - batchId: z.string().optional(), + // batchId for batch operations (for future batch-undo) + batchId: z.string().optional(), }) export function NodeStatusReason(nv: NodeVersion) { - const { t } = useNextTranslation() - const { node_id, status_reason } = nv - const { ref, inView } = useInView() - - // TODO: migrate this to comfy-api, bring node information to /versions - const { data: node } = useGetNode( - node_id!, - {}, - { query: { enabled: inView } } - ) + const { t } = useNextTranslation() + const { node_id, status_reason } = nv + const { ref, inView } = useInView() + + // TODO: migrate this to comfy-api, bring node information to /versions + const { data: node } = useGetNode( + node_id!, + {}, + { query: { enabled: inView } } + ) + + // Get last nodeversion, sorted by time + const { data: nodeVersions } = useListNodeVersions( + node_id!, + { include_status_reason: true }, + { query: { enabled: inView } } + ) + nodeVersions?.sort(compareBy((e) => e.createdAt || e.id || '')) + + // query last node versions + const currentNodeVersionIndex = + nodeVersions?.findIndex((nodeVersion) => nodeVersion.id === nv.id) ?? -1 + const lastApprovedNodeVersion = nodeVersions?.findLast( + (nv, i) => + nv.status === NodeVersionStatus.NodeVersionStatusActive && + i < currentNodeVersionIndex + ) + // const lastBannedNodeVersion = nodeVersions?.find( + // (nv, i) => + // nv.status === NodeVersionStatus.NodeVersionStatusBanned && + // i < currentNodeVersionIndex + // ) + // const lastFlaggedNodeVersion = nodeVersions?.find( + // (nv, i) => + // nv.status === NodeVersionStatus.NodeVersionStatusFlagged && + // i < currentNodeVersionIndex + // ) + // const lastNodeVersion = nodeVersions?.at(-2) + + // parse status reason + const issueList = parseIssueList(parseJsonSafe(status_reason ?? '').data) + // const lastVersionIssueList = parseIssueList(parseJsonSafe(lastNodeVersion.status_reason ?? '').data) + + // assume all issues are approved if last node version is approved + const approvedIssueList = parseIssueList( + parseJsonSafe( + zStatusReason + .safeParse( + parseJsonSafe(lastApprovedNodeVersion?.status_reason ?? '').data + ) + .data?.statusHistory?.findLast( + (e) => e.status === NodeVersionStatus.NodeVersionStatusFlagged + )?.message + ).data + ) + + // const statusReason = + // zStatusReason.safeParse(statusReasonJson).data ?? + // zStatusReason.parse({ message: status_reason, by: 'admin@comfy.org' }) + + const fullfilledIssueList = issueList + // guess url from node + ?.map((e) => { + const repoUrl = node?.repository || '' + const filepath = + repoUrl && + (e.file_path || '') && + `/blob/HEAD/${e.file_path?.replace(/^\//, '')}` + const linenumber = + filepath && (e.line_number || '') && `#L${e.line_number}` + const url = repoUrl + filepath + linenumber + return { ...e, url } + }) + // mark if the issue was approved before + ?.map((e) => { + const isApproved = approvedIssueList?.some( + (approvedIssue) => + approvedIssue.file_path === e.file_path && + approvedIssue.line_number === e.line_number && + approvedIssue.code_snippet === e.code_snippet + ) + return { ...e, isApproved } + }) - // Get last nodeversion, sorted by time - const { data: nodeVersions } = useListNodeVersions( - node_id!, - { include_status_reason: true }, - { query: { enabled: inView } } - ) - nodeVersions?.sort(compareBy((e) => e.createdAt || e.id || '')) - - // query last node versions - const currentNodeVersionIndex = - nodeVersions?.findIndex((nodeVersion) => nodeVersion.id === nv.id) ?? -1 - const lastApprovedNodeVersion = nodeVersions?.findLast( - (nv, i) => - nv.status === NodeVersionStatus.NodeVersionStatusActive && - i < currentNodeVersionIndex - ) - // const lastBannedNodeVersion = nodeVersions?.find( - // (nv, i) => - // nv.status === NodeVersionStatus.NodeVersionStatusBanned && - // i < currentNodeVersionIndex - // ) - // const lastFlaggedNodeVersion = nodeVersions?.find( - // (nv, i) => - // nv.status === NodeVersionStatus.NodeVersionStatusFlagged && - // i < currentNodeVersionIndex - // ) - // const lastNodeVersion = nodeVersions?.at(-2) - - // parse status reason - const issueList = parseIssueList(parseJsonSafe(status_reason ?? '').data) - // const lastVersionIssueList = parseIssueList(parseJsonSafe(lastNodeVersion.status_reason ?? '').data) - - // assume all issues are approved if last node version is approved - const approvedIssueList = parseIssueList( - parseJsonSafe( - zStatusReason - .safeParse( - parseJsonSafe(lastApprovedNodeVersion?.status_reason ?? '') - .data - ) - .data?.statusHistory?.findLast( - (e) => - e.status === NodeVersionStatus.NodeVersionStatusFlagged - )?.message - ).data - ) + const lastFullfilledIssueList = approvedIssueList // guess url from node + ?.map((e) => { + const repoUrl = node?.repository || '' + const filepath = + repoUrl && + (e.file_path || '') && + `/blob/HEAD/${e.file_path?.replace(/^\//, '')}` + const linenumber = + filepath && (e.line_number || '') && `#L${e.line_number}` + const url = repoUrl + filepath + linenumber + return { ...e, url } + }) + // mark if the issue was approved before + ?.map((e) => { + const isApproved = true + return { ...e, isApproved } + }) - // const statusReason = - // zStatusReason.safeParse(statusReasonJson).data ?? - // zStatusReason.parse({ message: status_reason, by: 'admin@comfy.org' }) - - const fullfilledIssueList = issueList - // guess url from node - ?.map((e) => { - const repoUrl = node?.repository || '' - const filepath = - repoUrl && - (e.file_path || '') && - `/blob/HEAD/${e.file_path?.replace(/^\//, '')}` - const linenumber = - filepath && (e.line_number || '') && `#L${e.line_number}` - const url = repoUrl + filepath + linenumber - return { ...e, url } - }) - // mark if the issue was approved before - ?.map((e) => { - const isApproved = approvedIssueList?.some( - (approvedIssue) => - approvedIssue.file_path === e.file_path && - approvedIssue.line_number === e.line_number && - approvedIssue.code_snippet === e.code_snippet - ) - return { ...e, isApproved } - }) - - const lastFullfilledIssueList = approvedIssueList // guess url from node - ?.map((e) => { - const repoUrl = node?.repository || '' - const filepath = - repoUrl && - (e.file_path || '') && - `/blob/HEAD/${e.file_path?.replace(/^\//, '')}` - const linenumber = - filepath && (e.line_number || '') && `#L${e.line_number}` - const url = repoUrl + filepath + linenumber - return { ...e, url } - }) - // mark if the issue was approved before - ?.map((e) => { - const isApproved = true - return { ...e, isApproved } - }) - - // get a summary for the issues, including weather it was approved before - const problemsSummary = fullfilledIssueList?.sort( - compareBy( - (e) => - // sort by approved before - (e.isApproved ? '0' : '1') + - // and then filepath + line number (padStart to order numbers by number, instead of string) - e.url - .split(/\b/) - .map( - (strOrNumber) => - z - .number() - .safeParse(strOrNumber) - .data?.toString() - .padStart(10, '0') ?? strOrNumber - ) - .join('') - ) + // get a summary for the issues, including weather it was approved before + const problemsSummary = fullfilledIssueList?.sort( + compareBy( + (e) => + // sort by approved before + (e.isApproved ? '0' : '1') + + // and then filepath + line number (padStart to order numbers by number, instead of string) + e.url + .split(/\b/) + .map( + (strOrNumber) => + z + .number() + .safeParse(strOrNumber) + .data?.toString() + .padStart(10, '0') ?? strOrNumber + ) + .join('') ) - - const lastCode = lastFullfilledIssueList - ? JSON.stringify(lastFullfilledIssueList) - : (lastApprovedNodeVersion?.status_reason ?? '') - const code = fullfilledIssueList - ? JSON.stringify(fullfilledIssueList) - : status_reason - return ( -
- {/* HistoryVersions */} - {(nodeVersions?.length ?? null) && ( -
- - - -

- - {t('Node history:')} -

-
    - {Object.entries( - nodeVersions!.reduce( - (acc, nv) => { - acc[nv.status!] = - (acc[nv.status!] || 0) + 1 - return acc - }, - {} as Record - ) - ).map(([status, count]) => ( -
  • - -
  • - ))} -
- - - - -
-
-
    - {nodeVersions?.map((nv) => ( -
  1. -
    - - {nv.version} - - - -
    - - {zStatusReason.safeParse( - nv.status_reason - ).data?.message ?? nv.status_reason} - {zStatusReason.safeParse( - nv.status_reason - ).data?.batchId && ( - - [Batch:{' '} - { - zStatusReason.safeParse( - nv.status_reason - ).data?.batchId - } - ] - - )} - -
  2. - ))} -
-
-
- )} - {!!problemsSummary?.length && ( - <> -

{'Problems Summary: '}

-
    - {problemsSummary.map((e, i) => ( -
  1. -
    - {/* show green checkmark if approved before */} - {e.isApproved ? ( - - ✅ - - ) : ( - - )} - - - - - {(e.file_path?.length ?? 0) > 18 + 2 - ? `…${e.file_path?.slice(-18)}` - : e.file_path} -  L{e.line_number} - -
    - -   - {e.issue_type} -   - {e.code_snippet || e.message} - -
  2. - ))} -
- - )} - {!!code?.trim() && ( -
- {'Status Reason: '} - {fullfilledIssueList ? ( - - ) : ( - {code} + ) + + const lastCode = lastFullfilledIssueList + ? JSON.stringify(lastFullfilledIssueList) + : (lastApprovedNodeVersion?.status_reason ?? '') + const code = fullfilledIssueList + ? JSON.stringify(fullfilledIssueList) + : status_reason + return ( +
+ {/* HistoryVersions */} + {(nodeVersions?.length ?? null) && ( +
+ + + +

+ + {t('Node history:')} +

+
    + {Object.entries( + nodeVersions!.reduce( + (acc, nv) => { + acc[nv.status!] = (acc[nv.status!] || 0) + 1 + return acc + }, + {} as Record + ) + ).map(([status, count]) => ( +
  • + +
  • + ))} +
+ + + + +
+
+
    + {nodeVersions?.map((nv) => ( +
  1. +
    + + {nv.version} + + + +
    + + {zStatusReason.safeParse(nv.status_reason).data?.message ?? + nv.status_reason} + {zStatusReason.safeParse(nv.status_reason).data + ?.batchId && ( + + [Batch:{' '} + { + zStatusReason.safeParse(nv.status_reason).data + ?.batchId + } + ] + )} -
- )} -
- ) + + + ))} + +
+ + )} + {!!problemsSummary?.length && ( + <> +

{'Problems Summary: '}

+
    + {problemsSummary.map((e, i) => ( +
  1. +
    + {/* show green checkmark if approved before */} + {e.isApproved ? ( + + ) : ( + + )} + + + + + {(e.file_path?.length ?? 0) > 18 + 2 + ? `…${e.file_path?.slice(-18)}` + : e.file_path} +  L{e.line_number} + +
    + +   + {e.issue_type} +   + {e.code_snippet || e.message} + +
  2. + ))} +
+ + )} + {!!code?.trim() && ( +
+ {'Status Reason: '} + {fullfilledIssueList ? ( + + ) : ( + {code} + )} +
+ )} + + ) } export function PrettieredJSON5({ children: raw }: { children: string }) { - const [code, setCode] = useState(raw) - useEffect(() => { - format(raw ?? '', { - parser: 'json5', - plugins: [prettierPluginBabel, prettierPluginEstree], - }).then(setCode) - }, [raw]) - return ( - - {code} - - ) + const [code, setCode] = useState(raw) + useEffect(() => { + format(raw ?? '', { + parser: 'json5', + plugins: [prettierPluginBabel, prettierPluginEstree], + }).then(setCode) + }, [raw]) + return ( + + {code} + + ) } export function PrettieredYAML({ children: raw }: { children: string }) { - const { ref, inView } = useInView() - - const parsedYaml = tryCatch( - (raw: string) => yaml.stringify(yaml.parse(raw)), - raw - )(raw) - - const [code, setCode] = useState(parsedYaml) - useEffect(() => { - format(parsedYaml, { - parser: 'yaml', - plugins: [prettierPluginYaml], - }).then(setCode) - }, [parsedYaml]) - - const [isEditorOpen, setEditorOpen] = useState(false) - const [editorReady, setEditorReady] = useState(false) - const displayEditor = isEditorOpen && editorReady - useEffect(() => { - if (isEditorOpen === false) setEditorReady(false) - }, [isEditorOpen]) - - return ( -
- {inView && ( -
- -
- )} - {!displayEditor && ( - - {code} - - )} - - {isEditorOpen && ( - setEditorReady(true)} - /> - )} + const { ref, inView } = useInView() + + const parsedYaml = tryCatch( + (raw: string) => yaml.stringify(yaml.parse(raw)), + raw + )(raw) + + const [code, setCode] = useState(parsedYaml) + useEffect(() => { + format(parsedYaml, { + parser: 'yaml', + plugins: [prettierPluginYaml], + }).then(setCode) + }, [parsedYaml]) + + const [isEditorOpen, setEditorOpen] = useState(false) + const [editorReady, setEditorReady] = useState(false) + const displayEditor = isEditorOpen && editorReady + useEffect(() => { + if (isEditorOpen === false) setEditorReady(false) + }, [isEditorOpen]) + + return ( +
+ {inView && ( +
+
- ) + )} + {!displayEditor && ( + + {code} + + )} + + {isEditorOpen && ( + setEditorReady(true)} + /> + )} +
+ ) } export function PrettieredYamlDiffView({ - original: rawOriginal, - modified: rawModified, + original: rawOriginal, + modified: rawModified, }: { - original: string - modified: string + original: string + modified: string }) { - const { ref, inView } = useInView() - - const parsedModified = tryCatch( - (raw: string) => raw && yaml.stringify(yaml.parse(raw)), - rawModified - )(rawModified) - const parsedOriginal = tryCatch( - (raw: string) => raw && yaml.stringify(yaml.parse(raw)), - rawOriginal - )(rawOriginal) - - const [codeOriginal, setCodeOriginal] = useState(parsedOriginal) - const [codeModified, setCodeModified] = useState(parsedModified) - - useEffect(() => { - format(parsedOriginal, { - parser: 'yaml', - plugins: [prettierPluginYaml], - }).then(setCodeOriginal) - }, [parsedOriginal]) - useEffect(() => { - format(parsedModified, { - parser: 'yaml', - plugins: [prettierPluginYaml], - }).then(setCodeModified) - }, [parsedModified]) - - const [isEditorOpen, setEditorOpen] = useState(true) - const [editorReady, setEditorReady] = useState(false) - const displayEditor = isEditorOpen && editorReady - useEffect(() => { - if (isEditorOpen === false) setEditorReady(false) - }, [isEditorOpen]) - - return ( -
- {inView && ( -
- -
- )} - - {isEditorOpen && ( - setEditorReady(true)} - /> - )} + const { ref, inView } = useInView() + + const parsedModified = tryCatch( + (raw: string) => raw && yaml.stringify(yaml.parse(raw)), + rawModified + )(rawModified) + const parsedOriginal = tryCatch( + (raw: string) => raw && yaml.stringify(yaml.parse(raw)), + rawOriginal + )(rawOriginal) + + const [codeOriginal, setCodeOriginal] = useState(parsedOriginal) + const [codeModified, setCodeModified] = useState(parsedModified) + + useEffect(() => { + format(parsedOriginal, { + parser: 'yaml', + plugins: [prettierPluginYaml], + }).then(setCodeOriginal) + }, [parsedOriginal]) + useEffect(() => { + format(parsedModified, { + parser: 'yaml', + plugins: [prettierPluginYaml], + }).then(setCodeModified) + }, [parsedModified]) + + const [isEditorOpen, setEditorOpen] = useState(true) + const [editorReady, setEditorReady] = useState(false) + const displayEditor = isEditorOpen && editorReady + useEffect(() => { + if (isEditorOpen === false) setEditorReady(false) + }, [isEditorOpen]) + + return ( +
+ {inView && ( +
+
- ) + )} + + {isEditorOpen && ( + setEditorReady(true)} + /> + )} +
+ ) } diff --git a/components/Search/Autocomplete.tsx b/components/Search/Autocomplete.tsx index e2516949..27186f18 100644 --- a/components/Search/Autocomplete.tsx +++ b/components/Search/Autocomplete.tsx @@ -7,12 +7,12 @@ import { createLocalStorageRecentSearchesPlugin } from '@algolia/autocomplete-pl import { debounce } from '@algolia/autocomplete-shared' import type { SearchClient } from 'algoliasearch/lite' import { - createElement, - Fragment, - useEffect, - useMemo, - useRef, - useState, + createElement, + Fragment, + useEffect, + useMemo, + useRef, + useState, } from 'react' import { createRoot, Root } from 'react-dom/client' import { usePagination, useSearchBox } from 'react-instantsearch' @@ -22,140 +22,140 @@ import { INSTANT_SEARCH_QUERY_SUGGESTIONS } from 'src/constants' import '@algolia/autocomplete-theme-classic' type AutocompleteProps = Partial> & { - searchClient: SearchClient - className?: string + searchClient: SearchClient + className?: string } type SetInstantSearchUiStateOptions = { - query: string + query: string } export default function Autocomplete({ - searchClient, - className, - ...autocompleteProps + searchClient, + className, + ...autocompleteProps }: AutocompleteProps) { - const autocompleteContainer = useRef(null) - const panelRootRef = useRef(null) - const rootRef = useRef(null) - - const { query, refine: setQuery } = useSearchBox() - - const { refine: setPage } = usePagination() - - const [instantSearchUiState, setInstantSearchUiState] = - useState({ query }) - const debouncedSetInstantSearchUiState = debounce( - setInstantSearchUiState, - 500 - ) - - useEffect(() => { - setQuery(instantSearchUiState.query) - setPage(0) - }, [instantSearchUiState, setQuery, setPage]) - - const plugins = useMemo(() => { - const recentSearches = createLocalStorageRecentSearchesPlugin({ - key: 'instantsearch', - limit: 3, - transformSource({ source }) { - return { - ...source, - onSelect({ item }) { - setInstantSearchUiState({ query: item.label }) - }, - } - }, + const autocompleteContainer = useRef(null) + const panelRootRef = useRef(null) + const rootRef = useRef(null) + + const { query, refine: setQuery } = useSearchBox() + + const { refine: setPage } = usePagination() + + const [instantSearchUiState, setInstantSearchUiState] = + useState({ query }) + const debouncedSetInstantSearchUiState = debounce( + setInstantSearchUiState, + 500 + ) + + useEffect(() => { + setQuery(instantSearchUiState.query) + setPage(0) + }, [instantSearchUiState, setQuery, setPage]) + + const plugins = useMemo(() => { + const recentSearches = createLocalStorageRecentSearchesPlugin({ + key: 'instantsearch', + limit: 3, + transformSource({ source }) { + return { + ...source, + onSelect({ item }) { + setInstantSearchUiState({ query: item.label }) + }, + } + }, + }) + + const querySuggestions = createQuerySuggestionsPlugin({ + searchClient, + indexName: INSTANT_SEARCH_QUERY_SUGGESTIONS, + getSearchParams() { + return recentSearches.data!.getAlgoliaSearchParams({ + hitsPerPage: 6, }) - - const querySuggestions = createQuerySuggestionsPlugin({ - searchClient, - indexName: INSTANT_SEARCH_QUERY_SUGGESTIONS, - getSearchParams() { - return recentSearches.data!.getAlgoliaSearchParams({ - hitsPerPage: 6, - }) - }, - transformSource({ source }) { - return { - ...source, - sourceId: 'querySuggestionsPlugin', - onSelect({ item }) { - setInstantSearchUiState({ - query: item.query, - }) - }, - getItems(params) { - if (!params.state.query) { - return [] - } - - return source.getItems(params) - }, - templates: { - ...source.templates, - header({ items }) { - if (items.length === 0) { - return - } - - return ( - - - In other categories - - - - ) - }, - }, - } + }, + transformSource({ source }) { + return { + ...source, + sourceId: 'querySuggestionsPlugin', + onSelect({ item }) { + setInstantSearchUiState({ + query: item.query, + }) + }, + getItems(params) { + if (!params.state.query) { + return [] + } + + return source.getItems(params) + }, + templates: { + ...source.templates, + header({ items }) { + if (items.length === 0) { + return + } + + return ( + + + In other categories + + + + ) }, + }, + } + }, + }) + + return [recentSearches, querySuggestions] + }, [searchClient]) + + useEffect(() => { + if (!autocompleteContainer.current) { + return + } + + const autocompleteInstance = autocomplete({ + ...autocompleteProps, + container: autocompleteContainer.current, + initialState: { query }, + insights: true, + plugins, + onReset() { + setInstantSearchUiState({ + query: '', }) - - return [recentSearches, querySuggestions] - }, [searchClient]) - - useEffect(() => { - if (!autocompleteContainer.current) { - return + }, + onSubmit({ state }) { + setInstantSearchUiState({ query: state.query }) + }, + onStateChange({ prevState, state }) { + if (prevState.query !== state.query) { + debouncedSetInstantSearchUiState({ query: state.query }) + } + }, + renderer: { createElement, Fragment, render: () => {} }, + render({ children }, root) { + if (!panelRootRef.current || rootRef.current !== root) { + rootRef.current = root + panelRootRef.current?.unmount() + panelRootRef.current = createRoot(root) } - const autocompleteInstance = autocomplete({ - ...autocompleteProps, - container: autocompleteContainer.current, - initialState: { query }, - insights: true, - plugins, - onReset() { - setInstantSearchUiState({ - query: '', - }) - }, - onSubmit({ state }) { - setInstantSearchUiState({ query: state.query }) - }, - onStateChange({ prevState, state }) { - if (prevState.query !== state.query) { - debouncedSetInstantSearchUiState({ query: state.query }) - } - }, - renderer: { createElement, Fragment, render: () => {} }, - render({ children }, root) { - if (!panelRootRef.current || rootRef.current !== root) { - rootRef.current = root - panelRootRef.current?.unmount() - panelRootRef.current = createRoot(root) - } - - panelRootRef.current.render(children) - }, - }) + panelRootRef.current.render(children) + }, + }) - return () => autocompleteInstance.destroy() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autocompleteProps, debouncedSetInstantSearchUiState, plugins]) + return () => autocompleteInstance.destroy() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autocompleteProps, debouncedSetInstantSearchUiState, plugins]) - return
+ return
} diff --git a/components/Search/EmptyQueryBoundary.tsx b/components/Search/EmptyQueryBoundary.tsx index 1d2564f4..8b505c59 100644 --- a/components/Search/EmptyQueryBoundary.tsx +++ b/components/Search/EmptyQueryBoundary.tsx @@ -2,28 +2,28 @@ import * as React from 'react' import { useInstantSearch } from 'react-instantsearch' type EmptyQueryBoundaryProps = { - children: React.ReactNode - fallback: React.ReactNode + children: React.ReactNode + fallback: React.ReactNode } const EmptyQueryBoundary: React.FC = ({ - children, - fallback, + children, + fallback, }) => { - const { indexUiState } = useInstantSearch() + const { indexUiState } = useInstantSearch() - // Render the fallback if the query is empty or too short - if (!indexUiState.query || indexUiState.query.length <= 1) { - return ( - <> - {fallback} - - - ) - } + // Render the fallback if the query is empty or too short + if (!indexUiState.query || indexUiState.query.length <= 1) { + return ( + <> + {fallback} + + + ) + } - // Render children if the query is valid - return <>{children} + // Render children if the query is valid + return <>{children} } export default EmptyQueryBoundary diff --git a/components/Search/PublisherId.tsx b/components/Search/PublisherId.tsx index 3f9cff43..c67dffb2 100644 --- a/components/Search/PublisherId.tsx +++ b/components/Search/PublisherId.tsx @@ -2,11 +2,11 @@ import { UNCLAIMED_ADMIN_PUBLISHER_ID } from '@/src/constants' import { useNextTranslation } from '@/src/hooks/i18n' export function PublisherId({ publisherId }: { publisherId?: string }) { - const { t } = useNextTranslation() - if (!publisherId) return null - return publisherId === UNCLAIMED_ADMIN_PUBLISHER_ID ? ( - {t('Unclaimed')} - ) : ( - {`@${publisherId}`} - ) + const { t } = useNextTranslation() + if (!publisherId) return null + return publisherId === UNCLAIMED_ADMIN_PUBLISHER_ID ? ( + {t('Unclaimed')} + ) : ( + {`@${publisherId}`} + ) } diff --git a/components/Search/SearchHit.stories.tsx b/components/Search/SearchHit.stories.tsx index e2e81381..47d92f6d 100644 --- a/components/Search/SearchHit.stories.tsx +++ b/components/Search/SearchHit.stories.tsx @@ -7,85 +7,84 @@ import { UNCLAIMED_ADMIN_PUBLISHER_ID } from '@/src/constants' // Create a wrapper component since Hit requires specific props structure const HitWrapper = (props) => { - // Mock the hit structure that would come from Algolia - const mockHit = { - id: props.id || '123', - name: props.name || 'Node Name', - description: props.description || 'This is a description of the node', - publisher_id: props.publisherId || 'pub-123', - total_install: props.totalInstall || 1500, - latest_version: props.latestVersion || '1.2.3', - comfy_nodes: props.comfyNodes || ['node1', 'node2'], - // Add required Algolia Hit properties - __position: 1, - __queryID: 'sample-query-id', - _highlightResult: { - comfy_nodes: props.comfyNodes?.map((node) => ({ - value: node, - matchedWords: props.matchedWords || ['node'], - })), - }, - _snippetResult: { - description: { - value: props.description || 'This is a description of the node', - }, - }, - objectID: props.id || '123', - } + // Mock the hit structure that would come from Algolia + const mockHit = { + id: props.id || '123', + name: props.name || 'Node Name', + description: props.description || 'This is a description of the node', + publisher_id: props.publisherId || 'pub-123', + total_install: props.totalInstall || 1500, + latest_version: props.latestVersion || '1.2.3', + comfy_nodes: props.comfyNodes || ['node1', 'node2'], + // Add required Algolia Hit properties + __position: 1, + __queryID: 'sample-query-id', + _highlightResult: { + comfy_nodes: props.comfyNodes?.map((node) => ({ + value: node, + matchedWords: props.matchedWords || ['node'], + })), + }, + _snippetResult: { + description: { + value: props.description || 'This is a description of the node', + }, + }, + objectID: props.id || '123', + } - return + return } const meta: Meta = { - title: 'Components/Search/SearchHit', - component: HitWrapper, - parameters: { - layout: 'centered', - backgrounds: { default: 'dark' }, - }, - tags: ['autodocs'], + title: 'Components/Search/SearchHit', + component: HitWrapper, + parameters: { + layout: 'centered', + backgrounds: { default: 'dark' }, + }, + tags: ['autodocs'], } export default meta type Story = StoryObj export const Default: Story = { - args: { - id: 'node-123', - name: 'Image Upscaler', - description: 'A node that upscales images using AI technology', - publisherId: 'pub-456', - totalInstall: 2500, - latestVersion: '2.0.1', - comfyNodes: ['upscaler', 'image-processor'], - matchedWords: ['upscaler'], - }, + args: { + id: 'node-123', + name: 'Image Upscaler', + description: 'A node that upscales images using AI technology', + publisherId: 'pub-456', + totalInstall: 2500, + latestVersion: '2.0.1', + comfyNodes: ['upscaler', 'image-processor'], + matchedWords: ['upscaler'], + }, } export const LongDescription: Story = { - args: { - id: 'node-456', - name: 'Advanced Texture Generator', - description: - 'This node generates procedural textures for 3D models with multiple parameters including roughness, metallic, and normal maps. It supports PBR workflows and can be integrated with various rendering engines.', - publisherId: 'pub-789', - totalInstall: 1800, - latestVersion: '1.5.3', - comfyNodes: ['texture', 'generator', 'procedural'], - matchedWords: ['texture', 'generator'], - }, + args: { + id: 'node-456', + name: 'Advanced Texture Generator', + description: + 'This node generates procedural textures for 3D models with multiple parameters including roughness, metallic, and normal maps. It supports PBR workflows and can be integrated with various rendering engines.', + publisherId: 'pub-789', + totalInstall: 1800, + latestVersion: '1.5.3', + comfyNodes: ['texture', 'generator', 'procedural'], + matchedWords: ['texture', 'generator'], + }, } export const UnclaimedNode: Story = { - args: { - id: 'node-789', - name: 'Unclaimed Node', - description: - 'This is an unclaimed node that can be claimed by a publisher', - publisherId: UNCLAIMED_ADMIN_PUBLISHER_ID, - totalInstall: 500, - latestVersion: '0.9.0', - comfyNodes: ['unclaimed'], - matchedWords: ['unclaimed'], - }, + args: { + id: 'node-789', + name: 'Unclaimed Node', + description: 'This is an unclaimed node that can be claimed by a publisher', + publisherId: UNCLAIMED_ADMIN_PUBLISHER_ID, + totalInstall: 500, + latestVersion: '0.9.0', + comfyNodes: ['unclaimed'], + matchedWords: ['unclaimed'], + }, } diff --git a/components/Search/SearchHit.tsx b/components/Search/SearchHit.tsx index e62b1c84..4a1fda62 100644 --- a/components/Search/SearchHit.tsx +++ b/components/Search/SearchHit.tsx @@ -1,9 +1,9 @@ import { ShortNumber } from '@lytieuphong/short-number' import { Tooltip } from 'flowbite-react' import type { - HitAttributeHighlightResult, - HitAttributeSnippetResult, - Hit as HitType, + HitAttributeHighlightResult, + HitAttributeSnippetResult, + Hit as HitType, } from 'instantsearch.js' import Link from 'next/link' import React from 'react' @@ -14,124 +14,114 @@ import { useNextTranslation } from '@/src/hooks/i18n' import { PublisherId } from './PublisherId' interface NodeHit { - id: string - name: string - description: string - publisher_id: string - total_install: number - latest_version: string - github_stars?: number + id: string + name: string + description: string + publisher_id: string + total_install: number + latest_version: string + github_stars?: number - comfy_nodes?: string[] + comfy_nodes?: string[] } type HitProps = { - hit: HitType + hit: HitType } const Hit: React.FC = ({ hit }) => { - const { t } = useNextTranslation() - const matchedNodes = ( - hit._highlightResult?.comfy_nodes as - | HitAttributeHighlightResult[] - | undefined - )?.filter((e) => (e.matchedWords as string[])?.length) - return ( - -
-
- -
+ const { t } = useNextTranslation() + const matchedNodes = ( + hit._highlightResult?.comfy_nodes as + | HitAttributeHighlightResult[] + | undefined + )?.filter((e) => (e.matchedWords as string[])?.length) + return ( + +
+
+ +
- {/* description */} - ( -

- {children} -

- ), - }} - > - {( - hit._snippetResult - ?.description as HitAttributeSnippetResult - )?.value.replace(/<\/?mark>/g, '**')} -
+ {/* description */} + ( +

+ {children} +

+ ), + }} + > + {( + hit._snippetResult?.description as HitAttributeSnippetResult + )?.value.replace(/<\/?mark>/g, '**')} +
- {/* nodes */} - {hit.comfy_nodes?.length && ( -
- - {hit.comfy_nodes?.join(', \n') ?? ''} - - } - placement="bottom" - > - {matchedNodes?.length ? ( - <> - {matchedNodes?.length ?? 0}/ - {hit.comfy_nodes?.length ?? 0} Nodes - matched: - {matchedNodes - ?.map((e) => - e.value?.replace(/<\/?mark>/g, '**') - ) - .filter((e) => e) - .map((name) => ( -
- {name} -
- ))} - - ) : ( - <>{hit.comfy_nodes?.length ?? 0} Nodes - )} -
-
- )} -
- {/* meta info */} -

+ + {hit.comfy_nodes?.join(', \n') ?? ''} + + } + placement="bottom" > - - {hit.latest_version && ( - - {' | '} - v{hit.latest_version} - - )} - {hit.total_install && ( - - {' | '} - {' '} - {ShortNumber(hit.total_install)} - - )} - {hit.github_stars && hit.github_stars > 0 && ( - - {' | '} - {' '} - {ShortNumber(hit.github_stars)} - - )} -

- - ) + {matchedNodes?.length ? ( + <> + {matchedNodes?.length ?? 0}/{hit.comfy_nodes?.length ?? 0}{' '} + Nodes matched: + {matchedNodes + ?.map((e) => e.value?.replace(/<\/?mark>/g, '**')) + .filter((e) => e) + .map((name) => ( +
+ {name} +
+ ))} + + ) : ( + <>{hit.comfy_nodes?.length ?? 0} Nodes + )} + +
+ )} +
+ {/* meta info */} +

+ + {hit.latest_version && ( + + {' | '} + v{hit.latest_version} + + )} + {hit.total_install && ( + + {' | '} + {' '} + {ShortNumber(hit.total_install)} + + )} + {hit.github_stars && hit.github_stars > 0 && ( + + {' | '} + {' '} + {ShortNumber(hit.github_stars)} + + )} +

+ + ) } export default Hit diff --git a/components/admin/AdminTreeNavigation.tsx b/components/admin/AdminTreeNavigation.tsx index aeb0c6b3..0ba6d90d 100644 --- a/components/admin/AdminTreeNavigation.tsx +++ b/components/admin/AdminTreeNavigation.tsx @@ -1,174 +1,170 @@ import Link from 'next/link' import { useState } from 'react' import { - HiChevronDown, - HiChevronRight, - HiOutlineAdjustments, - HiOutlineClipboardCheck, - HiOutlineCollection, - HiOutlineCube, - HiOutlineDuplicate, - HiOutlineSupport, + HiChevronDown, + HiChevronRight, + HiOutlineAdjustments, + HiOutlineClipboardCheck, + HiOutlineCollection, + HiOutlineCube, + HiOutlineDuplicate, + HiOutlineSupport, } from 'react-icons/hi' import { useNextTranslation } from '@/src/hooks/i18n' interface TreeNode { - id: string - label: string - icon: React.ComponentType<{ className?: string }> - href?: string - children?: TreeNode[] - expanded?: boolean + id: string + label: string + icon: React.ComponentType<{ className?: string }> + href?: string + children?: TreeNode[] + expanded?: boolean } interface AdminTreeNavigationProps { - className?: string + className?: string } export default function AdminTreeNavigation({ - className, + className, }: AdminTreeNavigationProps) { - const { t } = useNextTranslation() + const { t } = useNextTranslation() - const [expandedNodes, setExpandedNodes] = useState>( - new Set(['nodes', 'nodeversions']) - ) + const [expandedNodes, setExpandedNodes] = useState>( + new Set(['nodes', 'nodeversions']) + ) - const treeData: TreeNode[] = [ + const treeData: TreeNode[] = [ + { + id: 'nodes', + label: t('Nodes'), + icon: HiOutlineCube, + children: [ + { + id: 'manage-nodes', + label: t('Manage Nodes'), + icon: HiOutlineCollection, + href: '/admin/nodes', + }, { - id: 'nodes', - label: t('Nodes'), - icon: HiOutlineCube, - children: [ - { - id: 'manage-nodes', - label: t('Manage Nodes'), - icon: HiOutlineCollection, - href: '/admin/nodes', - }, - { - id: 'unclaimed-nodes', - label: t('Unclaimed Nodes'), - icon: HiOutlineCollection, - href: '/admin/claim-nodes', - }, - { - id: 'add-unclaimed-node', - label: t('Add Unclaimed Node'), - icon: HiOutlineCollection, - href: '/admin/add-unclaimed-node', - }, - ], + id: 'unclaimed-nodes', + label: t('Unclaimed Nodes'), + icon: HiOutlineCollection, + href: '/admin/claim-nodes', }, { - id: 'search-ranking', - label: t('Search Ranking Table'), - icon: HiOutlineAdjustments, - href: '/admin/search-ranking', + id: 'add-unclaimed-node', + label: t('Add Unclaimed Node'), + icon: HiOutlineCollection, + href: '/admin/add-unclaimed-node', }, + ], + }, + { + id: 'search-ranking', + label: t('Search Ranking Table'), + icon: HiOutlineAdjustments, + href: '/admin/search-ranking', + }, + { + id: 'comfy-node-names', + label: t('ComfyNode Names'), + icon: HiOutlineDuplicate, + href: '/admin/preempted-comfy-node-names', + }, + { + id: 'nodeversions', + label: t('Node Versions'), + icon: HiOutlineClipboardCheck, + children: [ { - id: 'comfy-node-names', - label: t('ComfyNode Names'), - icon: HiOutlineDuplicate, - href: '/admin/preempted-comfy-node-names', + id: 'review-versions', + label: t('Review Node Versions'), + icon: HiOutlineClipboardCheck, + href: '/admin/nodeversions?filter=flagged', }, { - id: 'nodeversions', - label: t('Node Versions'), - icon: HiOutlineClipboardCheck, - children: [ - { - id: 'review-versions', - label: t('Review Node Versions'), - icon: HiOutlineClipboardCheck, - href: '/admin/nodeversions?filter=flagged', - }, - { - id: 'version-compatibility', - label: t('Version Compatibility'), - icon: HiOutlineSupport, - href: '/admin/node-version-compatibility', - }, - ], + id: 'version-compatibility', + label: t('Version Compatibility'), + icon: HiOutlineSupport, + href: '/admin/node-version-compatibility', }, - ] + ], + }, + ] - const toggleExpanded = (nodeId: string) => { - const newExpanded = new Set(expandedNodes) - if (newExpanded.has(nodeId)) { - newExpanded.delete(nodeId) - } else { - newExpanded.add(nodeId) - } - setExpandedNodes(newExpanded) + const toggleExpanded = (nodeId: string) => { + const newExpanded = new Set(expandedNodes) + if (newExpanded.has(nodeId)) { + newExpanded.delete(nodeId) + } else { + newExpanded.add(nodeId) } + setExpandedNodes(newExpanded) + } - const renderTreeNode = (node: TreeNode, depth = 0) => { - const hasChildren = node.children && node.children.length > 0 - const isExpanded = expandedNodes.has(node.id) - const paddingLeft = depth * 20 + 12 - - return ( -
-
- {hasChildren && ( - - )} - {!hasChildren &&
} + const renderTreeNode = (node: TreeNode, depth = 0) => { + const hasChildren = node.children && node.children.length > 0 + const isExpanded = expandedNodes.has(node.id) + const paddingLeft = depth * 20 + 12 - + return ( +
+
+ {hasChildren && ( + + )} + {!hasChildren &&
} - {node.href ? ( - - {node.label} - - ) : ( - - hasChildren && toggleExpanded(node.id) - } - > - {node.label} - - )} -
+ - {hasChildren && isExpanded && ( -
- {node.children?.map((child) => - renderTreeNode(child, depth + 1) - )} -
- )} -
- ) - } + {node.href ? ( + + {node.label} + + ) : ( + hasChildren && toggleExpanded(node.id)} + > + {node.label} + + )} +
- return ( - + {hasChildren && isExpanded && ( +
+ {node.children?.map((child) => renderTreeNode(child, depth + 1))} +
+ )} +
) + } + + return ( + + ) } diff --git a/components/admin/NodeVersionCompatibilityEditModal.tsx b/components/admin/NodeVersionCompatibilityEditModal.tsx index 2b1329a5..d1472446 100644 --- a/components/admin/NodeVersionCompatibilityEditModal.tsx +++ b/components/admin/NodeVersionCompatibilityEditModal.tsx @@ -1,12 +1,12 @@ import { useQueryClient } from '@tanstack/react-query' import { AxiosError } from 'axios' import { - Alert, - Button, - Label, - Modal, - Textarea, - TextInput, + Alert, + Button, + Label, + Modal, + Textarea, + TextInput, } from 'flowbite-react' import Link from 'next/link' import { DIES } from 'phpdie' @@ -14,434 +14,394 @@ import React, { useEffect } from 'react' import { Controller, useForm } from 'react-hook-form' import { toast } from 'react-toastify' import { - INVALIDATE_CACHE_OPTION, - shouldInvalidate, + INVALIDATE_CACHE_OPTION, + shouldInvalidate, } from '@/components/cache-control' import { - AdminUpdateNodeVersionBody, - NodeVersion, - useAdminUpdateNodeVersion, - useGetNode, + AdminUpdateNodeVersionBody, + NodeVersion, + useAdminUpdateNodeVersion, + useGetNode, } from '@/src/api/generated' import { useNextTranslation } from '@/src/hooks/i18n' interface FormData { - supported_comfyui_frontend_version: string - supported_comfyui_version: string - supported_os: string - supported_accelerators: string + supported_comfyui_frontend_version: string + supported_comfyui_version: string + supported_os: string + supported_accelerators: string } interface NodeVersionCompatibilityEditModalProps { - isOpen: boolean - onClose: () => void - nodeVersion: NodeVersion | null - onSuccess?: () => void + isOpen: boolean + onClose: () => void + nodeVersion: NodeVersion | null + onSuccess?: () => void } export default function NodeVersionCompatibilityEditModal({ - isOpen, - onClose, - nodeVersion, - onSuccess, + isOpen, + onClose, + nodeVersion, + onSuccess, }: NodeVersionCompatibilityEditModalProps) { - const { t } = useNextTranslation() - const adminUpdateNodeVersion = useAdminUpdateNodeVersion() - const queryClient = useQueryClient() + const { t } = useNextTranslation() + const adminUpdateNodeVersion = useAdminUpdateNodeVersion() + const queryClient = useQueryClient() - // Fetch node information to get latest version - const { data: nodeData } = useGetNode( - nodeVersion?.node_id || '', - {}, - { - query: { enabled: !!nodeVersion?.node_id && isOpen }, - } + // Fetch node information to get latest version + const { data: nodeData } = useGetNode( + nodeVersion?.node_id || '', + {}, + { + query: { enabled: !!nodeVersion?.node_id && isOpen }, + } + ) + + const { + control, + handleSubmit, + reset, + formState: { isDirty }, + } = useForm({ + defaultValues: { + supported_comfyui_frontend_version: + nodeVersion?.supported_comfyui_frontend_version || '', + supported_comfyui_version: nodeVersion?.supported_comfyui_version || '', + supported_os: nodeVersion?.supported_os?.join('\n') || '', + supported_accelerators: + nodeVersion?.supported_accelerators?.join('\n') || '', + }, + }) + + // Reset form when nodeVersion changes + useEffect(() => { + if (nodeVersion && isOpen) { + reset({ + supported_comfyui_frontend_version: + nodeVersion.supported_comfyui_frontend_version || '', + supported_comfyui_version: nodeVersion.supported_comfyui_version || '', + supported_os: nodeVersion.supported_os?.join('\n') || '', + supported_accelerators: + nodeVersion.supported_accelerators?.join('\n') || '', + }) + } + }, [nodeVersion, isOpen, reset]) + + const normalizeSupportList = (text: string) => { + return ( + text + .split('\n') + .map((item) => item.trim()) + .filter(Boolean) + .toSorted() + // uniq + .reduce((acc, item) => { + if (!acc.includes(item)) acc.push(item) + return acc + }, []) ) + } - const { - control, - handleSubmit, - reset, - formState: { isDirty }, - } = useForm({ - defaultValues: { - supported_comfyui_frontend_version: - nodeVersion?.supported_comfyui_frontend_version || '', - supported_comfyui_version: - nodeVersion?.supported_comfyui_version || '', - supported_os: nodeVersion?.supported_os?.join('\n') || '', - supported_accelerators: - nodeVersion?.supported_accelerators?.join('\n') || '', + const onSubmit = async (data: FormData) => { + if (!nodeVersion) return + + try { + await adminUpdateNodeVersion.mutateAsync({ + nodeId: + nodeVersion.node_id || + DIES(toast.error.bind(toast), t('Node ID is required')), + versionNumber: + nodeVersion.version || + DIES(toast.error.bind(toast), t('Node Version Number is required')), + data: { + supported_comfyui_frontend_version: + data.supported_comfyui_frontend_version, + supported_comfyui_version: data.supported_comfyui_version, + supported_os: normalizeSupportList(data.supported_os), + supported_accelerators: normalizeSupportList( + data.supported_accelerators + ), }, - }) + }) - // Reset form when nodeVersion changes - useEffect(() => { - if (nodeVersion && isOpen) { - reset({ - supported_comfyui_frontend_version: - nodeVersion.supported_comfyui_frontend_version || '', - supported_comfyui_version: - nodeVersion.supported_comfyui_version || '', - supported_os: nodeVersion.supported_os?.join('\n') || '', - supported_accelerators: - nodeVersion.supported_accelerators?.join('\n') || '', - }) - } - }, [nodeVersion, isOpen, reset]) + // Cache-busting invalidation for cached endpoints + queryClient.fetchQuery( + shouldInvalidate.getListNodeVersionsQueryOptions( + nodeVersion.node_id!, + undefined, + INVALIDATE_CACHE_OPTION + ) + ) - const normalizeSupportList = (text: string) => { - return ( - text - .split('\n') - .map((item) => item.trim()) - .filter(Boolean) - .toSorted() - // uniq - .reduce((acc, item) => { - if (!acc.includes(item)) acc.push(item) - return acc - }, []) + toast.success(t('Updated node version compatibility')) + onClose() + onSuccess?.() + } catch (e) { + if (e instanceof AxiosError) { + const errorMessage = e.response?.data?.message || t('Unknown error') + toast.error( + t('Failed to update node version: {{error}}', { + error: errorMessage, + }) ) + return + } + toast.error(t('Failed to update node version')) } + } - const onSubmit = async (data: FormData) => { - if (!nodeVersion) return + const handleClose = () => { + reset() + onClose() + } - try { - await adminUpdateNodeVersion.mutateAsync({ - nodeId: - nodeVersion.node_id || - DIES(toast.error.bind(toast), t('Node ID is required')), - versionNumber: - nodeVersion.version || - DIES( - toast.error.bind(toast), - t('Node Version Number is required') - ), - data: { - supported_comfyui_frontend_version: - data.supported_comfyui_frontend_version, - supported_comfyui_version: data.supported_comfyui_version, - supported_os: normalizeSupportList(data.supported_os), - supported_accelerators: normalizeSupportList( - data.supported_accelerators - ), - }, - }) + if (!nodeVersion) return null - // Cache-busting invalidation for cached endpoints - queryClient.fetchQuery( - shouldInvalidate.getListNodeVersionsQueryOptions( - nodeVersion.node_id!, - undefined, - INVALIDATE_CACHE_OPTION - ) - ) - - toast.success(t('Updated node version compatibility')) - onClose() - onSuccess?.() - } catch (e) { - if (e instanceof AxiosError) { - const errorMessage = - e.response?.data?.message || t('Unknown error') - toast.error( - t('Failed to update node version: {{error}}', { - error: errorMessage, - }) - ) - return + return ( + + {t('Edit Node Version Compatibility')} +
{ + // allow ctrl+Enter to submit the form + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + // check if focus is on a text input or textarea + const activeElement = document.activeElement + if ( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement + ) { + e.preventDefault() + handleSubmit(onSubmit)() } - toast.error(t('Failed to update node version')) - } - } - - const handleClose = () => { - reset() - onClose() - } + } + }} + > + +
+
+
+ + {t('Specification Reference:')} + {' '} + + {t('pyproject.toml - ComfyUI')} + +
+
- if (!nodeVersion) return null +
+ +
+ {nodeVersion.node_id}@{nodeVersion.version} +
+
- return ( - - {t('Edit Node Version Compatibility')} - { - // allow ctrl+Enter to submit the form - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - // check if focus is on a text input or textarea - const activeElement = document.activeElement - if ( - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement - ) { - e.preventDefault() - handleSubmit(onSubmit)() - } - } - }} - > - -
-
-
- - {t('Specification Reference:')} - {' '} - - {t('pyproject.toml - ComfyUI')} - -
+ {/* Latest Version Compatibility Info */} + {nodeData?.latest_version && + nodeData.latest_version.version !== nodeVersion.version && ( + +
+
+
+ {t('Latest Version Compatibility Reference')}:{' '} + {`v${nodeData.latest_version.version}`} +
+ +
+
+ {nodeData.latest_version + .supported_comfyui_frontend_version && ( +
+ + {t('ComfyUI Frontend')}: + {' '} + { + nodeData.latest_version + .supported_comfyui_frontend_version + }
- + )} + {nodeData.latest_version.supported_comfyui_version && (
- -
- {nodeVersion.node_id}@{nodeVersion.version} -
+ {t('ComfyUI')}:{' '} + {nodeData.latest_version.supported_comfyui_version}
- - {/* Latest Version Compatibility Info */} - {nodeData?.latest_version && - nodeData.latest_version.version !== - nodeVersion.version && ( - -
-
-
- {t( - 'Latest Version Compatibility Reference' - )} - :{' '} - {`v${nodeData.latest_version.version}`} -
- -
-
- {nodeData.latest_version - .supported_comfyui_frontend_version && ( -
- - {t('ComfyUI Frontend')}: - {' '} - { - nodeData.latest_version - .supported_comfyui_frontend_version - } -
- )} - {nodeData.latest_version - .supported_comfyui_version && ( -
- - {t('ComfyUI')}: - {' '} - { - nodeData.latest_version - .supported_comfyui_version - } -
- )} - {nodeData.latest_version - .supported_os && - nodeData.latest_version - .supported_os.length > - 0 && ( -
- - {t('OS')}: - {' '} - {nodeData.latest_version.supported_os.join( - ', ' - )} -
- )} - {nodeData.latest_version - .supported_accelerators && - nodeData.latest_version - .supported_accelerators - .length > 0 && ( -
- - {t('Accelerators')}: - {' '} - {nodeData.latest_version.supported_accelerators.join( - ', ' - )} -
- )} -
-
- {t( - 'Consider using these values for better compatibility with the latest version' - )} -
-
-
+ )} + {nodeData.latest_version.supported_os && + nodeData.latest_version.supported_os.length > 0 && ( +
+ {t('OS')}:{' '} + {nodeData.latest_version.supported_os.join(', ')} +
+ )} + {nodeData.latest_version.supported_accelerators && + nodeData.latest_version.supported_accelerators.length > + 0 && ( +
+ + {t('Accelerators')}: + {' '} + {nodeData.latest_version.supported_accelerators.join( + ', ' )} +
+ )} +
+
+ {t( + 'Consider using these values for better compatibility with the latest version' + )} +
+
+
+ )} - {nodeData?.latest_version && - nodeData.latest_version.version === - nodeVersion.version && ( - -
- {t('This is the latest version')} -
-
- {t( - 'You are editing compatibility settings for the most recent version of this node' - )} -
-
- )} + {nodeData?.latest_version && + nodeData.latest_version.version === nodeVersion.version && ( + +
+ {t('This is the latest version')} +
+
+ {t( + 'You are editing compatibility settings for the most recent version of this node' + )} +
+
+ )} -
- - ( - - )} - /> -
+
+ + ( + + )} + /> +
-
- - ( - - )} - /> -
+
+ + ( + + )} + /> +
-
- -
- {t('Enter one OS per line')} -
- ( -