Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ const _mswApp = initialize({
onUnhandledRequest: 'bypass',
})

const languageName = (lang: string) =>
new Intl.DisplayNames(lang, { type: 'language' }).of(lang)

const preview: Preview = {
parameters: {
controls: {
Expand Down Expand Up @@ -68,12 +65,12 @@ const preview: Preview = {
toolbar: {
icon: 'globe',
items: [
{ value: 'en', right: '🇺🇸', title: languageName('en') },
{ value: 'es', right: '🇪🇸', title: languageName('es') },
{ value: 'fr', right: '🇫🇷', title: languageName('fr') },
{ value: 'ja', right: '🇯🇵', title: languageName('ja') },
{ value: 'kr', right: '🇰🇷', title: languageName('kr') },
{ value: 'zh', right: '🇨🇳', title: languageName('zh') },
{ value: 'en', right: '🇺🇸', title: 'English' },
{ value: 'es', right: '🇪🇸', title: 'Español' },
{ value: 'fr', right: '🇫🇷', title: 'Français' },
{ value: 'ja', right: '🇯🇵', title: '日本語' },
{ value: 'kr', right: '🇰🇷', title: '한국어' },
{ value: 'zh', right: '🇨🇳', title: '中文' },
],
},
},
Expand Down
4 changes: 4 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "comfy-org-registry-web",
Expand Down Expand Up @@ -69,6 +70,7 @@
"react-use-cookie": "^1.6.1",
"sflow": "^1.24.5",
"sharp": "^0.34.3",
"smol-toml": "^1.4.2",
"styled-jsx": "^5.1.7",
"yaml": "^2.8.1",
"zod": "^3.25.76",
Expand Down Expand Up @@ -2531,6 +2533,8 @@

"slice-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],

"smol-toml": ["[email protected]", "", {}, "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g=="],

"source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],

"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
Expand Down
121 changes: 121 additions & 0 deletions components/nodes/AdminCreateNodeFormModal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
import { AdminCreateNodeFormModal } from './AdminCreateNodeFormModal'

const meta = {
title: 'Components/Nodes/AdminCreateNodeFormModal',
component: AdminCreateNodeFormModal,
parameters: {
layout: 'centered',
docs: {
description: {
component: `
The AdminCreateNodeFormModal is used by administrators to add unclaimed nodes to the registry.
It features a repository URL input at the top with a "Fetch Info" button that automatically
populates form fields from the pyproject.toml file in GitHub repositories.

## Features
- Repository URL input with auto-fetch functionality
- Form validation using Zod schema
- Duplicate node detection
- Integration with React Hook Form
- Toast notifications for success/error states
`,
},
},
},
decorators: [
(Story) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return (
<QueryClientProvider client={queryClient}>
<div style={{ minHeight: '600px' }}>
<Story />
</div>
</QueryClientProvider>
)
},
],
tags: ['autodocs'],
} satisfies Meta<typeof AdminCreateNodeFormModal>

export default meta
type Story = StoryObj<typeof meta>

// Story wrapper component to handle modal state
function ModalWrapper(
args: React.ComponentProps<typeof AdminCreateNodeFormModal>
) {
const [open, setOpen] = useState(true)

return (
<AdminCreateNodeFormModal
{...args}
open={open}
onClose={() => {
console.log('Modal closed')
setOpen(false)
// Reopen after a delay for demo purposes
setTimeout(() => setOpen(true), 1000)
}}
/>
)
}

export const Default: Story = {
render: (args) => <ModalWrapper {...args} />,
args: {
open: true,
onClose: () => console.log('onClose'),
},
}

export const WithGitHubRepo: Story = {
render: (args) => <ModalWrapper {...args} />,
args: {
open: true,
onClose: () => console.log('onClose'),
},
parameters: {
docs: {
description: {
story: `
This story demonstrates the modal with a GitHub repository URL pre-filled.
In a real scenario, clicking "Fetch Info" would populate the form fields
with data from the repository's pyproject.toml file.
`,
},
},
},
play: async ({ canvasElement }) => {
// You could add play interactions here to demonstrate the functionality
// For example, filling in the repository field and clicking fetch
},
}

export const Closed: Story = {
render: () => (
<AdminCreateNodeFormModal
open={false}
onClose={() => console.log('onClose')}
/>
),
args: {
open: false,
onClose: () => console.log('onClose'),
},
parameters: {
docs: {
description: {
story: 'The modal in its closed state.',
},
},
},
}
170 changes: 163 additions & 7 deletions components/nodes/AdminCreateNodeFormModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { useQueryClient } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { Button, Label, Modal, Textarea, TextInput } from 'flowbite-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { HiPlus } from 'react-icons/hi'
import { HiDownload, HiPlus } from 'react-icons/hi'
import { toast } from 'react-toastify'
import TOML from 'smol-toml'
import { customThemeTModal } from 'utils/comfyTheme'
import { z } from 'zod'
import {
Expand Down Expand Up @@ -47,6 +49,104 @@ const adminCreateNodeDefaultValues: Partial<
license: '{file="LICENSE"}',
}

interface PyProjectData {
name?: string
description?: string
author?: string
license?: string
}

async function fetchGitHubRepoInfo(
repoUrl: string
): Promise<PyProjectData | null> {
try {
// Parse GitHub URL to extract owner and repo
const urlMatch = repoUrl.match(
/github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/|$)/
)
if (!urlMatch) {
throw new Error('Invalid GitHub URL format')
}

const [, owner, repo] = urlMatch
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/pyproject.toml`

const response = await fetch(apiUrl)
if (!response.ok) {
if (response.status === 404) {
throw new Error('pyproject.toml not found in repository')
}
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing rate limit handling for GitHub API requests. The GitHub API has rate limits (60 requests/hour for unauthenticated requests, 5000/hour for authenticated). Consider adding error handling for HTTP 403 responses with rate limit information, or implementing request throttling to prevent users from hitting rate limits.

Suggested change
}
}
// Handle GitHub API rate limit (HTTP 403)
if (response.status === 403) {
const rateLimitRemaining = response.headers.get('X-RateLimit-Remaining')
const rateLimitReset = response.headers.get('X-RateLimit-Reset')
let errorMsg = 'GitHub API rate limit exceeded.'
if (rateLimitReset) {
const resetDate = new Date(parseInt(rateLimitReset, 10) * 1000)
errorMsg += ` You can retry after ${resetDate.toLocaleString()}.`
}
throw new Error(errorMsg)
}

Copilot uses AI. Check for mistakes.
throw new Error(`GitHub API error: ${response.statusText}`)
}

const data = await response.json()
// Validate encoding and base64 content
if (data.encoding !== 'base64') {
throw new Error(
`Unexpected encoding for pyproject.toml: ${data.encoding}`
)
}
// Basic base64 validation regex (allows padding)
const base64Regex =
/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/
if (!base64Regex.test(data.content)) {
throw new Error('Invalid base64 content in pyproject.toml')
}
Comment on lines +90 to +94
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern used to validate base64 content doesn't allow whitespace characters (spaces, newlines), which are commonly present in base64-encoded content from the GitHub API. This could cause valid base64 content to be rejected. Consider removing this validation or using .replace(/\s/g, '') to strip whitespace before validation.

Copilot uses AI. Check for mistakes.
const content = atob(data.content)
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The atob() function is not available in Node.js environments. This will cause the code to fail during server-side rendering or in Node.js contexts. Consider using Buffer.from(data.content, 'base64').toString('utf-8') which works in both browser and Node.js environments.

Suggested change
const content = atob(data.content)
const content = Buffer.from(data.content, 'base64').toString('utf-8')

Copilot uses AI. Check for mistakes.

// Parse TOML using proper TOML library
const parsed = TOML.parse(content)

const result: PyProjectData = {}

// Extract project metadata
if (parsed.project && typeof parsed.project === 'object') {
const project = parsed.project as any

// Extract name
if (typeof project.name === 'string') {
result.name = project.name
}

// Extract description
if (typeof project.description === 'string') {
result.description = project.description
}

// Extract author (from authors array or single author field)
if (Array.isArray(project.authors) && project.authors.length > 0) {
const firstAuthor = project.authors[0]
if (
typeof firstAuthor === 'object' &&
typeof firstAuthor.name === 'string'
) {
result.author = firstAuthor.name
}
} else if (typeof project.author === 'string') {
result.author = project.author
}

// Extract license
if (typeof project.license === 'string') {
result.license = project.license
} else if (
typeof project.license === 'object' &&
project.license !== null
) {
const license = project.license as any
if (typeof license.text === 'string') {
result.license = license.text
}
}
}

return result
} catch (error) {
console.error('Error fetching GitHub repo info:', error)
throw error
}
}

export function AdminCreateNodeFormModal({
open,
onClose,
Expand All @@ -56,6 +156,8 @@ export function AdminCreateNodeFormModal({
}) {
const { t } = useNextTranslation()
const qc = useQueryClient()
const [isFetching, setIsFetching] = useState(false)

const mutation = useAdminCreateNode({
mutation: {
onError: (error) => {
Expand Down Expand Up @@ -83,6 +185,7 @@ export function AdminCreateNodeFormModal({
formState: { errors },
watch,
reset,
setValue,
} = useForm<Node>({
resolver: zodResolver(adminCreateNodeSchema) as any,
defaultValues: adminCreateNodeDefaultValues,
Expand All @@ -106,6 +209,37 @@ export function AdminCreateNodeFormModal({
})
})

const handleFetchRepoInfo = async () => {
const repository = watch('repository')
if (!repository) {
toast.error(t('Please enter a repository URL first'))
return
}

setIsFetching(true)
try {
const repoData = await fetchGitHubRepoInfo(repository)
if (repoData) {
if (repoData.name) setValue('name', repoData.name)
if (repoData.description) setValue('description', repoData.description)
if (repoData.author) setValue('author', repoData.author)
if (repoData.license) setValue('license', repoData.license)

toast.success(t('Repository information fetched successfully'))
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error'
toast.error(
t('Failed to fetch repository information: {{error}}', {
error: errorMessage,
})
)
} finally {
setIsFetching(false)
}
}

const { data: allPublishers } = useListPublishers({
query: { enabled: false },
}) // list publishers for unclaimed user
Expand Down Expand Up @@ -146,6 +280,34 @@ export function AdminCreateNodeFormModal({
>
<p className="text-white">{t('Add unclaimed node')}</p>

<div>
<Label htmlFor="repository">{t('Repository URL')}</Label>
<div className="flex gap-2">
<TextInput
id="repository"
{...register('repository')}
placeholder="https://github.com/user/repo"
className="flex-1"
/>
<Button
type="button"
size="sm"
onClick={handleFetchRepoInfo}
disabled={isFetching}
className="whitespace-nowrap"
>
<HiDownload className="mr-2 h-4 w-4" />
{isFetching ? t('Fetching...') : t('Fetch Info')}
</Button>
</div>
<span className="text-error">{errors.repository?.message}</span>
<p className="text-xs text-gray-400 mt-1">
{t(
'Enter a GitHub repository URL and click "Fetch Info" to automatically fill in details from pyproject.toml'
)}
</p>
</div>

<div>
<Label htmlFor="id">{t('ID')}</Label>
<TextInput id="id" {...register('id')} />
Expand Down Expand Up @@ -204,12 +366,6 @@ export function AdminCreateNodeFormModal({
<span className="text-error">{errors.author?.message}</span>
</div>

<div>
<Label htmlFor="repository">{t('Repository')}</Label>
<TextInput id="repository" {...register('repository')} />
<span className="text-error">{errors.repository?.message}</span>
</div>

<div>
<Label htmlFor="license">{t('License')}</Label>
<TextInput id="license" {...register('license')} />
Expand Down
Loading