mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
feat(publish): allow uploading a skill folder directly
Publishing required a hand-made .zip. Local agent skills live as folders, and repackaging them by hand often smuggles in tool metadata (__MACOSX/, .DS_Store, ...) that clutters the package (#737). Add an "upload a folder" option to the publish dropzone: pick a folder (webkitdirectory), and the browser packages it into a clean STORE-method zip via a dependency-free writer, dropping VCS/build/OS junk (.git/, node_modules/, __pycache__/, .DS_Store, ...). The resulting zip flows through the exact same upload/publish path, and the server already strips a single root directory, so paths are preserved as-is. Packaging (folder-zip.ts) is isolated and unit-tested (CRC-32 vectors, zip structure, junk filtering); the UploadZone gains an optional onFolderSelect prop so existing callers are unaffected. Closes #737 Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
This commit is contained in:
parent
954dfce7a4
commit
0fbc55f917
6 changed files with 384 additions and 42 deletions
89
web/src/features/publish/folder-zip.test.ts
Normal file
89
web/src/features/publish/folder-zip.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectFolderEntries,
|
||||
createZipBlob,
|
||||
crc32,
|
||||
isIgnoredPath,
|
||||
packageFolderAsZip,
|
||||
} from './folder-zip'
|
||||
|
||||
const utf8 = new TextEncoder()
|
||||
|
||||
function fileAt(relativePath: string, content = 'x'): File {
|
||||
const name = relativePath.split('/').pop() || relativePath
|
||||
const file = new File([content], name)
|
||||
Object.defineProperty(file, 'webkitRelativePath', { value: relativePath })
|
||||
return file
|
||||
}
|
||||
|
||||
async function bytesOf(blob: Blob): Promise<Uint8Array> {
|
||||
return new Uint8Array(await blob.arrayBuffer())
|
||||
}
|
||||
|
||||
describe('isIgnoredPath', () => {
|
||||
it('keeps normal skill files', () => {
|
||||
expect(isIgnoredPath('my-skill/SKILL.md')).toBe(false)
|
||||
expect(isIgnoredPath('my-skill/scripts/run.sh')).toBe(false)
|
||||
})
|
||||
|
||||
it('drops VCS, build and OS junk', () => {
|
||||
expect(isIgnoredPath('my-skill/.git/config')).toBe(true)
|
||||
expect(isIgnoredPath('my-skill/node_modules/x/index.js')).toBe(true)
|
||||
expect(isIgnoredPath('my-skill/__pycache__/m.pyc')).toBe(true)
|
||||
expect(isIgnoredPath('my-skill/.DS_Store')).toBe(true)
|
||||
expect(isIgnoredPath('my-skill/._resource')).toBe(true)
|
||||
expect(isIgnoredPath('my-skill/Thumbs.db')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('crc32', () => {
|
||||
it('matches known CRC-32/ISO-HDLC vectors', () => {
|
||||
expect(crc32(utf8.encode(''))).toBe(0x00000000)
|
||||
expect(crc32(utf8.encode('a'))).toBe(0xe8b7be43)
|
||||
expect(crc32(utf8.encode('abc'))).toBe(0x352441c2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createZipBlob', () => {
|
||||
it('writes a STORE archive with local, central and EOCD records', async () => {
|
||||
const blob = createZipBlob([{ path: 'SKILL.md', data: utf8.encode('hello') }])
|
||||
const bytes = await bytesOf(blob)
|
||||
const view = new DataView(bytes.buffer)
|
||||
|
||||
// Local file header signature at offset 0.
|
||||
expect(view.getUint32(0, true)).toBe(0x04034b50)
|
||||
// Contains a central directory header and an end-of-central-directory record.
|
||||
const eocd = bytes.length - 22
|
||||
expect(view.getUint32(eocd, true)).toBe(0x06054b50)
|
||||
expect(view.getUint16(eocd + 10, true)).toBe(1) // total entries
|
||||
// Central dir offset points at a central directory header signature.
|
||||
const cdOffset = view.getUint32(eocd + 16, true)
|
||||
expect(view.getUint32(cdOffset, true)).toBe(0x02014b50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectFolderEntries', () => {
|
||||
it('filters junk and sorts remaining files by path', async () => {
|
||||
const entries = await collectFolderEntries([
|
||||
fileAt('my-skill/scripts/run.sh', 'run'),
|
||||
fileAt('my-skill/.git/config', 'gitcfg'),
|
||||
fileAt('my-skill/SKILL.md', 'md'),
|
||||
])
|
||||
expect(entries.map((e) => e.path)).toEqual(['my-skill/SKILL.md', 'my-skill/scripts/run.sh'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('packageFolderAsZip', () => {
|
||||
it('names the zip after the top-level folder', async () => {
|
||||
const file = await packageFolderAsZip([fileAt('my-skill/SKILL.md', 'md')])
|
||||
expect(file.name).toBe('my-skill.zip')
|
||||
expect(file.type).toBe('application/zip')
|
||||
expect(file.size).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('throws when everything was filtered out', async () => {
|
||||
await expect(packageFolderAsZip([fileAt('my-skill/.git/config', 'x')])).rejects.toThrow(
|
||||
'empty-folder'
|
||||
)
|
||||
})
|
||||
})
|
||||
189
web/src/features/publish/folder-zip.ts
Normal file
189
web/src/features/publish/folder-zip.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* Dependency-free packaging of a selected folder into a skill ZIP.
|
||||
*
|
||||
* Browsers expose a picked folder as a flat FileList (each File carries a
|
||||
* `webkitRelativePath` like `my-skill/SKILL.md`). We build a STORE-method ZIP
|
||||
* (no compression — skill packages are small text files, and STORE keeps this
|
||||
* dependency-free) from those files so the result flows through the exact same
|
||||
* upload/publish path as a hand-made ZIP.
|
||||
*
|
||||
* We drop VCS/build/OS junk that a real on-disk folder almost always contains
|
||||
* (`.git/`, `node_modules/`, `.DS_Store`, …) so it never bloats the package or
|
||||
* the file-count limit. The server additionally strips a single root directory
|
||||
* and OS-metadata entries, so paths are kept as-is (`my-skill/SKILL.md`).
|
||||
*/
|
||||
|
||||
/** Directory names whose entire subtree is excluded from the package. */
|
||||
const IGNORED_DIR_SEGMENTS = new Set([
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
'node_modules',
|
||||
'__pycache__',
|
||||
'__MACOSX',
|
||||
])
|
||||
|
||||
/** Exact file names that are always excluded. */
|
||||
const IGNORED_FILE_NAMES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini'])
|
||||
|
||||
/** Returns true if a relative path should be excluded from the package. */
|
||||
export function isIgnoredPath(relativePath: string): boolean {
|
||||
const parts = relativePath.split('/')
|
||||
const name = parts[parts.length - 1]
|
||||
if (!name) return true // trailing slash / directory marker
|
||||
if (parts.some((segment) => IGNORED_DIR_SEGMENTS.has(segment))) return true
|
||||
if (IGNORED_FILE_NAMES.has(name)) return true
|
||||
if (name.startsWith('._')) return true
|
||||
if (name.endsWith('.pyc') || name.endsWith('.swp')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// --- CRC-32 (IEEE 802.3, polynomial 0xEDB88320) -------------------------------
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256)
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
}
|
||||
table[n] = c >>> 0
|
||||
}
|
||||
return table
|
||||
})()
|
||||
|
||||
export function crc32(bytes: Uint8Array): number {
|
||||
let crc = 0xffffffff
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8)
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
// --- ZIP writer (STORE method, no data descriptors) ---------------------------
|
||||
|
||||
export interface ZipEntry {
|
||||
path: string
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
const utf8 = new TextEncoder()
|
||||
|
||||
/**
|
||||
* Builds a ZIP archive (STORE method) containing the given entries and returns
|
||||
* it as a Blob. 32-bit size fields are used; skill packages are far below the
|
||||
* 4 GB boundary where ZIP64 would be required.
|
||||
*/
|
||||
export function createZipBlob(entries: ZipEntry[]): Blob {
|
||||
const localParts: Uint8Array[] = []
|
||||
const centralParts: Uint8Array[] = []
|
||||
let offset = 0
|
||||
|
||||
for (const entry of entries) {
|
||||
const nameBytes = utf8.encode(entry.path)
|
||||
const crc = crc32(entry.data)
|
||||
const size = entry.data.length
|
||||
|
||||
const local = new Uint8Array(30 + nameBytes.length)
|
||||
const lv = new DataView(local.buffer)
|
||||
lv.setUint32(0, 0x04034b50, true) // local file header signature
|
||||
lv.setUint16(4, 20, true) // version needed
|
||||
lv.setUint16(6, 0x0800, true) // flags: bit 11 = UTF-8 names
|
||||
lv.setUint16(8, 0, true) // method: STORE
|
||||
lv.setUint16(10, 0, true) // mod time
|
||||
lv.setUint16(12, 0, true) // mod date
|
||||
lv.setUint32(14, crc, true)
|
||||
lv.setUint32(18, size, true) // compressed size (== uncompressed for STORE)
|
||||
lv.setUint32(22, size, true) // uncompressed size
|
||||
lv.setUint16(26, nameBytes.length, true)
|
||||
lv.setUint16(28, 0, true) // extra length
|
||||
local.set(nameBytes, 30)
|
||||
|
||||
localParts.push(local, entry.data)
|
||||
|
||||
const central = new Uint8Array(46 + nameBytes.length)
|
||||
const cv = new DataView(central.buffer)
|
||||
cv.setUint32(0, 0x02014b50, true) // central directory header signature
|
||||
cv.setUint16(4, 20, true) // version made by
|
||||
cv.setUint16(6, 20, true) // version needed
|
||||
cv.setUint16(8, 0x0800, true) // flags
|
||||
cv.setUint16(10, 0, true) // method: STORE
|
||||
cv.setUint16(12, 0, true) // mod time
|
||||
cv.setUint16(14, 0, true) // mod date
|
||||
cv.setUint32(16, crc, true)
|
||||
cv.setUint32(20, size, true)
|
||||
cv.setUint32(24, size, true)
|
||||
cv.setUint16(28, nameBytes.length, true)
|
||||
cv.setUint16(30, 0, true) // extra length
|
||||
cv.setUint16(32, 0, true) // comment length
|
||||
cv.setUint16(34, 0, true) // disk number start
|
||||
cv.setUint16(36, 0, true) // internal attrs
|
||||
cv.setUint32(38, 0, true) // external attrs
|
||||
cv.setUint32(42, offset, true) // relative offset of local header
|
||||
central.set(nameBytes, 46)
|
||||
centralParts.push(central)
|
||||
|
||||
offset += local.length + entry.data.length
|
||||
}
|
||||
|
||||
const centralSize = centralParts.reduce((n, p) => n + p.length, 0)
|
||||
const eocd = new Uint8Array(22)
|
||||
const ev = new DataView(eocd.buffer)
|
||||
ev.setUint32(0, 0x06054b50, true) // end of central directory signature
|
||||
ev.setUint16(4, 0, true) // disk number
|
||||
ev.setUint16(6, 0, true) // central dir start disk
|
||||
ev.setUint16(8, entries.length, true) // entries on this disk
|
||||
ev.setUint16(10, entries.length, true) // total entries
|
||||
ev.setUint32(12, centralSize, true) // central dir size
|
||||
ev.setUint32(16, offset, true) // central dir offset
|
||||
ev.setUint16(20, 0, true) // comment length
|
||||
|
||||
// Concatenate into a single buffer so the Blob part is a Uint8Array<ArrayBuffer>.
|
||||
const parts = [...localParts, ...centralParts, eocd]
|
||||
const total = parts.reduce((n, p) => n + p.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let pos = 0
|
||||
for (const part of parts) {
|
||||
out.set(part, pos)
|
||||
pos += part.length
|
||||
}
|
||||
return new Blob([out], { type: 'application/zip' })
|
||||
}
|
||||
|
||||
// --- Folder -> File ------------------------------------------------------------
|
||||
|
||||
/** Reads the picked folder's files into ZIP entries, skipping junk. */
|
||||
export async function collectFolderEntries(files: File[]): Promise<ZipEntry[]> {
|
||||
const entries: ZipEntry[] = []
|
||||
for (const file of files) {
|
||||
const path = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
|
||||
if (isIgnoredPath(path)) continue
|
||||
const data = new Uint8Array(await file.arrayBuffer())
|
||||
entries.push({ path, data })
|
||||
}
|
||||
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Top-level folder name of a webkitdirectory selection, for naming the zip. */
|
||||
function rootFolderName(files: File[]): string {
|
||||
for (const file of files) {
|
||||
const rel = (file as File & { webkitRelativePath?: string }).webkitRelativePath
|
||||
if (rel && rel.includes('/')) return rel.slice(0, rel.indexOf('/'))
|
||||
}
|
||||
return 'skill'
|
||||
}
|
||||
|
||||
/**
|
||||
* Packages a picked folder into a `<folder>.zip` File ready for the existing
|
||||
* upload flow. Throws if every file was filtered out as junk.
|
||||
*/
|
||||
export async function packageFolderAsZip(fileList: FileList | File[]): Promise<File> {
|
||||
const files = Array.from(fileList)
|
||||
const entries = await collectFolderEntries(files)
|
||||
if (entries.length === 0) {
|
||||
throw new Error('empty-folder')
|
||||
}
|
||||
const blob = createZipBlob(entries)
|
||||
return new File([blob], `${rootFolderName(files)}.zip`, { type: 'application/zip' })
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect, useRef, type ChangeEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface UploadZoneProps {
|
||||
onFileSelect: (file: File) => void
|
||||
/** Optional: called with the raw files of a picked folder (webkitdirectory). */
|
||||
onFolderSelect?: (files: File[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -13,8 +15,20 @@ interface UploadZoneProps {
|
|||
* The component is intentionally stateless so packaging validation can remain in
|
||||
* the publish flow that knows the surrounding form and backend constraints.
|
||||
*/
|
||||
export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
|
||||
export function UploadZone({ onFileSelect, onFolderSelect, disabled }: UploadZoneProps) {
|
||||
const { t } = useTranslation()
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// `webkitdirectory` / `directory` are not in React's input attribute types;
|
||||
// set them imperatively so the folder picker works without an untyped cast.
|
||||
useEffect(() => {
|
||||
const el = folderInputRef.current
|
||||
if (el) {
|
||||
el.setAttribute('webkitdirectory', '')
|
||||
el.setAttribute('directory', '')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
|
|
@ -33,44 +47,75 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
|
|||
disabled,
|
||||
})
|
||||
|
||||
const handleFolderChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files
|
||||
if (files && files.length > 0) {
|
||||
onFolderSelect?.(Array.from(files))
|
||||
}
|
||||
// Reset so picking the same folder again re-triggers change.
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'upload-zone rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
|
||||
isDragActive && 'border-primary bg-primary/5 scale-[1.01]',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-2xl bg-secondary/60 flex items-center justify-center">
|
||||
<svg
|
||||
className={cn(
|
||||
'w-7 h-7 upload-zone-icon transition-colors',
|
||||
isDragActive && 'text-primary'
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{isDragActive ? (
|
||||
<p className="text-sm text-primary font-medium">{t('upload.dropHint')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-foreground">{t('upload.dragHint')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('upload.formatHint')}</p>
|
||||
</>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'upload-zone rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
|
||||
isDragActive && 'border-primary bg-primary/5 scale-[1.01]',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-2xl bg-secondary/60 flex items-center justify-center">
|
||||
<svg
|
||||
className={cn(
|
||||
'w-7 h-7 upload-zone-icon transition-colors',
|
||||
isDragActive && 'text-primary'
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{isDragActive ? (
|
||||
<p className="text-sm text-primary font-medium">{t('upload.dropHint')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-foreground">{t('upload.dragHint')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('upload.formatHint')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onFolderSelect && (
|
||||
<div className="text-center">
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleFolderChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('upload.folderHint')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1212,7 +1212,8 @@
|
|||
"upload": {
|
||||
"dropHint": "Drop to upload...",
|
||||
"dragHint": "Drag a ZIP file here, or click to select",
|
||||
"formatHint": "Only .zip format supported"
|
||||
"formatHint": "Only .zip format supported",
|
||||
"folderHint": "Or select a folder to package and upload"
|
||||
},
|
||||
"layout": {
|
||||
"footerDescription": "Skill registry, providing efficient skill management and distribution for developers."
|
||||
|
|
@ -1389,7 +1390,8 @@
|
|||
"warningConfirmCancel": "Go back and fix",
|
||||
"frontmatterFailedTitle": "SKILL.md format is invalid",
|
||||
"frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.",
|
||||
"selectRequired": "Please select namespace and file"
|
||||
"selectRequired": "Please select namespace and file",
|
||||
"folderPackagingFailed": "Could not package the selected folder. Make sure it contains files."
|
||||
},
|
||||
"toast": {
|
||||
"success": "Success",
|
||||
|
|
|
|||
|
|
@ -1212,7 +1212,8 @@
|
|||
"upload": {
|
||||
"dropHint": "放开以上传文件...",
|
||||
"dragHint": "拖拽 ZIP 文件到此处,或点击选择",
|
||||
"formatHint": "仅支持 .zip 格式"
|
||||
"formatHint": "仅支持 .zip 格式",
|
||||
"folderHint": "或选择文件夹,自动打包上传"
|
||||
},
|
||||
"layout": {
|
||||
"footerDescription": "技能注册中心,为开发者提供高效的技能管理和分发平台。"
|
||||
|
|
@ -1389,7 +1390,8 @@
|
|||
"warningConfirmCancel": "返回修改",
|
||||
"frontmatterFailedTitle": "SKILL.md 格式有误",
|
||||
"frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。",
|
||||
"selectRequired": "请选择命名空间和文件"
|
||||
"selectRequired": "请选择命名空间和文件",
|
||||
"folderPackagingFailed": "无法打包所选文件夹,请确认其中包含文件。"
|
||||
},
|
||||
"toast": {
|
||||
"success": "成功",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UploadZone } from '@/features/publish/upload-zone'
|
||||
import { packageFolderAsZip } from '@/features/publish/folder-zip'
|
||||
import {
|
||||
extractPrecheckWarnings,
|
||||
isFrontmatterFailureMessage,
|
||||
|
|
@ -41,6 +42,7 @@ export function PublishPage() {
|
|||
const [visibility, setVisibility] = useState<string>(prefill.visibility)
|
||||
const [warningDialogOpen, setWarningDialogOpen] = useState(false)
|
||||
const [precheckWarnings, setPrecheckWarnings] = useState<string[]>([])
|
||||
const [isPackaging, setIsPackaging] = useState(false)
|
||||
|
||||
const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
|
||||
const publishMutation = usePublishSkill()
|
||||
|
|
@ -66,6 +68,18 @@ export function PublishPage() {
|
|||
setWarningDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleFolderSelect = async (files: File[]) => {
|
||||
setIsPackaging(true)
|
||||
try {
|
||||
const zip = await packageFolderAsZip(files)
|
||||
handleFileSelect(zip)
|
||||
} catch {
|
||||
toast.error(t('publish.folderPackagingFailed'))
|
||||
} finally {
|
||||
setIsPackaging(false)
|
||||
}
|
||||
}
|
||||
|
||||
const publishSkill = async (confirmWarnings = false) => {
|
||||
if (!selectedFile || !namespaceSlug) {
|
||||
toast.error(t('publish.selectRequired'))
|
||||
|
|
@ -201,7 +215,8 @@ export function PublishPage() {
|
|||
<Label className="text-sm font-semibold font-heading">{t('publish.file')}</Label>
|
||||
<UploadZone
|
||||
onFileSelect={handleFileSelect}
|
||||
disabled={publishMutation.isPending}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
disabled={publishMutation.isPending || isPackaging}
|
||||
/>
|
||||
{selectedFile && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/30 px-4 py-3">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue