mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat(upload): add archive extraction and form polish
This commit is contained in:
@@ -14,3 +14,4 @@ count.txt
|
||||
todos.json
|
||||
.cta.json
|
||||
.vscode
|
||||
.env*.local
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/* @vitest-environment node */
|
||||
import { gzipSync, strToU8, zipSync } from 'fflate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { expandFiles } from './uploadFiles'
|
||||
|
||||
if (typeof File === 'undefined') {
|
||||
class NodeFile extends Blob {
|
||||
name: string
|
||||
lastModified: number
|
||||
|
||||
constructor(parts: BlobPart[], name: string, options?: FilePropertyBag) {
|
||||
super(parts, options)
|
||||
this.name = name
|
||||
this.lastModified = options?.lastModified ?? Date.now()
|
||||
}
|
||||
}
|
||||
// @ts-expect-error Node test environment polyfill
|
||||
globalThis.File = NodeFile
|
||||
}
|
||||
|
||||
function buildTar(entries: Array<{ name: string; content: string }>) {
|
||||
const blocks: Uint8Array[] = []
|
||||
for (const entry of entries) {
|
||||
const content = strToU8(entry.content)
|
||||
const header = new Uint8Array(512)
|
||||
writeString(header, entry.name, 0, 100)
|
||||
writeString(header, '0000777', 100, 8)
|
||||
writeString(header, '0000000', 108, 8)
|
||||
writeString(header, '0000000', 116, 8)
|
||||
writeString(header, content.length.toString(8).padStart(11, '0'), 124, 12)
|
||||
writeString(header, '00000000000', 136, 12)
|
||||
header[156] = '0'.charCodeAt(0)
|
||||
writeString(header, 'ustar', 257, 6)
|
||||
for (let i = 148; i < 156; i += 1) {
|
||||
header[i] = 32
|
||||
}
|
||||
let sum = 0
|
||||
for (const byte of header) sum += byte
|
||||
writeString(header, sum.toString(8).padStart(6, '0'), 148, 6)
|
||||
header[154] = 0
|
||||
header[155] = 32
|
||||
blocks.push(header)
|
||||
blocks.push(content)
|
||||
const pad = (512 - (content.length % 512)) % 512
|
||||
if (pad) blocks.push(new Uint8Array(pad))
|
||||
}
|
||||
blocks.push(new Uint8Array(1024))
|
||||
const total = blocks.reduce((sum, block) => sum + block.length, 0)
|
||||
const buffer = new Uint8Array(total)
|
||||
let offset = 0
|
||||
for (const block of blocks) {
|
||||
buffer.set(block, offset)
|
||||
offset += block.length
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
function writeString(target: Uint8Array, value: string, start: number, length: number) {
|
||||
const bytes = strToU8(value)
|
||||
target.set(bytes.subarray(0, length), start)
|
||||
}
|
||||
|
||||
describe('expandFiles', () => {
|
||||
it('expands zip archives into files', async () => {
|
||||
const zip = zipSync({
|
||||
'SKILL.md': strToU8('hello'),
|
||||
'docs/readme.txt': strToU8('doc'),
|
||||
})
|
||||
const zipFile = new File([zip], 'pack.zip', { type: 'application/zip' })
|
||||
const result = await expandFiles([zipFile])
|
||||
expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'docs/readme.txt'])
|
||||
})
|
||||
|
||||
it('expands gzipped tar archives into files', async () => {
|
||||
const tar = buildTar([
|
||||
{ name: 'SKILL.md', content: 'hi' },
|
||||
{ name: 'notes.txt', content: 'yo' },
|
||||
])
|
||||
const tgz = gzipSync(tar)
|
||||
const tgzFile = new File([tgz], 'bundle.tgz', { type: 'application/gzip' })
|
||||
const result = await expandFiles([tgzFile])
|
||||
expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt'])
|
||||
})
|
||||
|
||||
it('expands .gz single files', async () => {
|
||||
const gz = gzipSync(strToU8('content'))
|
||||
const gzFile = new File([gz], 'skill.md.gz', { type: 'application/gzip' })
|
||||
const result = await expandFiles([gzFile])
|
||||
expect(result.map((file) => file.name)).toEqual(['skill.md'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { gunzipSync, unzipSync } from 'fflate'
|
||||
|
||||
const TEXT_TYPES = new Map([
|
||||
['md', 'text/markdown'],
|
||||
['markdown', 'text/markdown'],
|
||||
['txt', 'text/plain'],
|
||||
['json', 'application/json'],
|
||||
['yaml', 'text/yaml'],
|
||||
['yml', 'text/yaml'],
|
||||
['toml', 'text/plain'],
|
||||
['js', 'text/javascript'],
|
||||
['ts', 'text/plain'],
|
||||
['tsx', 'text/plain'],
|
||||
['jsx', 'text/plain'],
|
||||
['css', 'text/css'],
|
||||
['html', 'text/html'],
|
||||
['svg', 'image/svg+xml'],
|
||||
])
|
||||
|
||||
export async function expandFiles(selected: File[]) {
|
||||
const expanded: File[] = []
|
||||
for (const file of selected) {
|
||||
const lower = file.name.toLowerCase()
|
||||
if (lower.endsWith('.zip')) {
|
||||
const entries = unzipSync(new Uint8Array(await readArrayBuffer(file)))
|
||||
for (const [path, data] of Object.entries(entries)) {
|
||||
if (!path || path.endsWith('/')) continue
|
||||
expanded.push(
|
||||
new File([data], normalizePath(path), {
|
||||
type: guessContentType(path),
|
||||
}),
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) {
|
||||
const unpacked = gunzipSync(new Uint8Array(await readArrayBuffer(file)))
|
||||
for (const entry of untar(unpacked)) {
|
||||
expanded.push(
|
||||
new File([entry.data], normalizePath(entry.path), {
|
||||
type: guessContentType(entry.path),
|
||||
}),
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (lower.endsWith('.gz')) {
|
||||
const unpacked = gunzipSync(new Uint8Array(await readArrayBuffer(file)))
|
||||
const name = file.name.replace(/\.gz$/i, '')
|
||||
expanded.push(new File([unpacked], name, { type: guessContentType(name) }))
|
||||
continue
|
||||
}
|
||||
expanded.push(file)
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
async function readArrayBuffer(file: Blob) {
|
||||
if (typeof file.arrayBuffer === 'function') {
|
||||
return file.arrayBuffer()
|
||||
}
|
||||
return new Response(file).arrayBuffer()
|
||||
}
|
||||
|
||||
function guessContentType(path: string) {
|
||||
const ext = path.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return 'text/plain'
|
||||
return TEXT_TYPES.get(ext) ?? 'text/plain'
|
||||
}
|
||||
|
||||
function normalizePath(path: string) {
|
||||
return path.replace(/^\.\/+/, '').replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
function untar(bytes: Uint8Array) {
|
||||
const entries: Array<{ path: string; data: Uint8Array }> = []
|
||||
let offset = 0
|
||||
while (offset + 512 <= bytes.length) {
|
||||
const header = bytes.subarray(offset, offset + 512)
|
||||
if (header.every((byte) => byte === 0)) break
|
||||
const name = readString(header.subarray(0, 100))
|
||||
const size = readOctal(header.subarray(124, 136))
|
||||
const typeflag = header[156]
|
||||
offset += 512
|
||||
const data = bytes.subarray(offset, offset + size)
|
||||
offset += Math.ceil(size / 512) * 512
|
||||
if (!name || typeflag === 53) continue
|
||||
entries.push({ path: name, data })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function readString(bytes: Uint8Array) {
|
||||
const end = bytes.indexOf(0)
|
||||
const slice = end === -1 ? bytes : bytes.subarray(0, end)
|
||||
return new TextDecoder().decode(slice).trim()
|
||||
}
|
||||
|
||||
function readOctal(bytes: Uint8Array) {
|
||||
const raw = readString(bytes)
|
||||
return raw ? Number.parseInt(raw, 8) : 0
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { Upload } from './upload'
|
||||
|
||||
const generateUploadUrl = vi.fn()
|
||||
const publishVersion = vi.fn()
|
||||
|
||||
vi.mock('convex/react', () => ({
|
||||
useConvexAuth: () => ({ isAuthenticated: true }),
|
||||
useMutation: () => generateUploadUrl,
|
||||
useAction: () => publishVersion,
|
||||
}))
|
||||
|
||||
describe('Upload route', () => {
|
||||
beforeEach(() => {
|
||||
generateUploadUrl.mockReset()
|
||||
publishVersion.mockReset()
|
||||
})
|
||||
|
||||
it('shows validation issues and disables publish by default', () => {
|
||||
render(<Upload />)
|
||||
const publishButton = screen.getByRole('button', { name: /publish/i })
|
||||
expect(publishButton).toBeTruthy()
|
||||
expect((publishButton as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByText(/Slug is required/i)).toBeTruthy()
|
||||
expect(screen.getByText(/Display name is required/i)).toBeTruthy()
|
||||
expect(screen.getByText(/Changelog is required/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('marks the input for folder uploads', async () => {
|
||||
render(<Upload />)
|
||||
const input = screen.getByTestId('upload-input')
|
||||
await waitFor(() => {
|
||||
expect(input.getAttribute('webkitdirectory')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('enables publish when fields and files are valid, and allows removing files', async () => {
|
||||
render(<Upload />)
|
||||
fireEvent.change(screen.getByPlaceholderText('my-skill-pack'), {
|
||||
target: { value: 'cool-skill' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('My Skill Pack'), {
|
||||
target: { value: 'Cool Skill' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('1.0.0'), {
|
||||
target: { value: '1.2.3' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('latest, beta'), {
|
||||
target: { value: 'latest' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('What changed in this version?'), {
|
||||
target: { value: 'Initial drop.' },
|
||||
})
|
||||
|
||||
const file = new File(['hello'], 'SKILL.md', { type: 'text/markdown' })
|
||||
const input = screen.getByTestId('upload-input') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
const publishButton = screen.getByRole('button', { name: /publish/i }) as HTMLButtonElement
|
||||
await waitFor(() => {
|
||||
expect(publishButton.disabled).toBe(false)
|
||||
})
|
||||
expect(screen.getByText(/Ready to publish/i)).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /remove/i }))
|
||||
await waitFor(() => {
|
||||
expect(publishButton.disabled).toBe(true)
|
||||
})
|
||||
expect(screen.getByText(/Add at least one file/i)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
+292
-76
@@ -1,13 +1,17 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useAction, useConvexAuth, useMutation } from 'convex/react'
|
||||
import { useState } from 'react'
|
||||
import semver from 'semver'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import { expandFiles } from '../lib/uploadFiles'
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
|
||||
export const Route = createFileRoute('/upload')({
|
||||
component: Upload,
|
||||
})
|
||||
|
||||
function Upload() {
|
||||
export function Upload() {
|
||||
const { isAuthenticated } = useConvexAuth()
|
||||
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl)
|
||||
const publishVersion = useAction(api.skills.publishVersion)
|
||||
@@ -19,6 +23,78 @@ function Upload() {
|
||||
const [changelog, setChangelog] = useState('')
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const maxBytes = 50 * 1024 * 1024
|
||||
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files])
|
||||
const hasSkillFile = useMemo(
|
||||
() =>
|
||||
files.some(
|
||||
(file) => file.name.toLowerCase() === 'skill.md' || file.name.toLowerCase() === 'skills.md',
|
||||
),
|
||||
[files],
|
||||
)
|
||||
const sizeLabel = totalBytes ? formatBytes(totalBytes) : '0 B'
|
||||
const trimmedSlug = slug.trim()
|
||||
const trimmedName = displayName.trim()
|
||||
const trimmedChangelog = changelog.trim()
|
||||
const parsedTags = useMemo(
|
||||
() =>
|
||||
tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
[tags],
|
||||
)
|
||||
const validation = useMemo(() => {
|
||||
const issues: string[] = []
|
||||
if (!trimmedSlug) {
|
||||
issues.push('Slug is required.')
|
||||
} else if (!SLUG_PATTERN.test(trimmedSlug)) {
|
||||
issues.push('Slug must be lowercase and use dashes only.')
|
||||
}
|
||||
if (!trimmedName) {
|
||||
issues.push('Display name is required.')
|
||||
}
|
||||
if (!semver.valid(version)) {
|
||||
issues.push('Version must be valid semver (e.g. 1.0.0).')
|
||||
}
|
||||
if (parsedTags.length === 0) {
|
||||
issues.push('At least one tag is required.')
|
||||
}
|
||||
if (!trimmedChangelog) {
|
||||
issues.push('Changelog is required.')
|
||||
}
|
||||
if (files.length === 0) {
|
||||
issues.push('Add at least one file.')
|
||||
}
|
||||
if (!hasSkillFile) {
|
||||
issues.push('SKILL.md is required.')
|
||||
}
|
||||
if (totalBytes > maxBytes) {
|
||||
issues.push('Total file size exceeds 50MB.')
|
||||
}
|
||||
return {
|
||||
issues,
|
||||
ready: issues.length === 0,
|
||||
}
|
||||
}, [
|
||||
trimmedSlug,
|
||||
trimmedName,
|
||||
version,
|
||||
parsedTags.length,
|
||||
trimmedChangelog,
|
||||
files.length,
|
||||
hasSkillFile,
|
||||
totalBytes,
|
||||
maxBytes,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileInputRef.current) return
|
||||
fileInputRef.current.setAttribute('webkitdirectory', '')
|
||||
fileInputRef.current.setAttribute('directory', '')
|
||||
}, [])
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
@@ -30,18 +106,13 @@ function Upload() {
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (files.length === 0) return
|
||||
if (!validation.ready) return
|
||||
setError(null)
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.size, 0)
|
||||
if (totalBytes > 50 * 1024 * 1024) {
|
||||
if (totalBytes > maxBytes) {
|
||||
setError('Total size exceeds 50MB per version.')
|
||||
return
|
||||
}
|
||||
if (
|
||||
!files.some(
|
||||
(file) => file.name.toLowerCase() === 'skill.md' || file.name.toLowerCase() === 'skills.md',
|
||||
)
|
||||
) {
|
||||
if (!hasSkillFile) {
|
||||
setError('SKILL.md is required.')
|
||||
return
|
||||
}
|
||||
@@ -84,74 +155,207 @@ function Upload() {
|
||||
setStatus('Published.')
|
||||
}
|
||||
|
||||
async function handleFilesSelected(selected: File[]) {
|
||||
if (selected.length === 0) return
|
||||
setError(null)
|
||||
setStatus('Preparing files…')
|
||||
const expanded = await expandFiles(selected)
|
||||
setStatus(null)
|
||||
const next = new Map<string, File>()
|
||||
for (const file of files) {
|
||||
const key = `${file.webkitRelativePath || file.name}:${file.size}`
|
||||
next.set(key, file)
|
||||
}
|
||||
for (const file of expanded) {
|
||||
const key = `${file.webkitRelativePath || file.name}:${file.size}`
|
||||
next.set(key, file)
|
||||
}
|
||||
setFiles(Array.from(next.values()))
|
||||
}
|
||||
|
||||
function handleRemoveFile(target: File) {
|
||||
setFiles((current) =>
|
||||
current.filter(
|
||||
(file) =>
|
||||
`${file.webkitRelativePath || file.name}:${file.size}` !==
|
||||
`${target.webkitRelativePath || target.name}:${target.size}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault()
|
||||
setIsDragging(false)
|
||||
handleFilesSelected(Array.from(event.dataTransfer.files ?? []))
|
||||
}
|
||||
|
||||
function handleDragOver(event: React.DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<h1 className="section-title">Publish a skill</h1>
|
||||
<p className="section-subtitle">Bundle SKILL.md + text files, then ship.</p>
|
||||
<form className="card" onSubmit={handleSubmit} style={{ display: 'grid', gap: 16 }}>
|
||||
<label>
|
||||
Slug
|
||||
<input
|
||||
className="search-input"
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
placeholder="my-skill-pack"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Display name
|
||||
<input
|
||||
className="search-input"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="My Skill Pack"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Version
|
||||
<input
|
||||
className="search-input"
|
||||
value={version}
|
||||
onChange={(event) => setVersion(event.target.value)}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Tags (comma-separated)
|
||||
<input
|
||||
className="search-input"
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
placeholder="latest, beta"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Changelog
|
||||
<textarea
|
||||
className="search-input"
|
||||
rows={3}
|
||||
value={changelog}
|
||||
onChange={(event) => setChangelog(event.target.value)}
|
||||
placeholder="What changed in this version?"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Files (must include SKILL.md)
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(event) => setFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn btn-primary" type="submit">
|
||||
Publish
|
||||
</button>
|
||||
{error ? (
|
||||
<div className="stat" style={{ color: '#b84a3a' }}>
|
||||
{error}
|
||||
<main className="section upload-shell">
|
||||
<header className="upload-header">
|
||||
<div>
|
||||
<span className="upload-kicker">Publish</span>
|
||||
<h1 className="upload-title">Publish a skill</h1>
|
||||
<p className="upload-subtitle">Bundle SKILL.md + text files. Tag it, version it, ship it.</p>
|
||||
</div>
|
||||
<div className="upload-badge">
|
||||
50 MB max
|
||||
<span className="upload-badge-sub">per version</span>
|
||||
</div>
|
||||
</header>
|
||||
<form className="upload-card" onSubmit={handleSubmit}>
|
||||
<div className="upload-grid">
|
||||
<div className="upload-fields">
|
||||
<label className="upload-field">
|
||||
<span>Slug</span>
|
||||
<input
|
||||
className="search-input upload-input"
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
placeholder="my-skill-pack"
|
||||
/>
|
||||
</label>
|
||||
<label className="upload-field">
|
||||
<span>Display name</span>
|
||||
<input
|
||||
className="search-input upload-input"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="My Skill Pack"
|
||||
/>
|
||||
</label>
|
||||
<div className="upload-row">
|
||||
<label className="upload-field">
|
||||
<span>Version</span>
|
||||
<input
|
||||
className="search-input upload-input"
|
||||
value={version}
|
||||
onChange={(event) => setVersion(event.target.value)}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
</label>
|
||||
<label className="upload-field">
|
||||
<span>Tags</span>
|
||||
<input
|
||||
className="search-input upload-input"
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
placeholder="latest, beta"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="upload-field">
|
||||
<span>Changelog</span>
|
||||
<textarea
|
||||
className="search-input upload-input"
|
||||
rows={4}
|
||||
value={changelog}
|
||||
onChange={(event) => setChangelog(event.target.value)}
|
||||
placeholder="What changed in this version?"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
{status ? <div className="stat">{status}</div> : null}
|
||||
<div className="upload-side">
|
||||
<div
|
||||
className={`dropzone${isDragging ? ' is-dragging' : ''}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<div className="dropzone-icon">⬇</div>
|
||||
<div>
|
||||
<strong>Drop a folder, files, or zip</strong>
|
||||
<p>Click to choose a folder. Archives auto-extract.</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
className="dropzone-input"
|
||||
type="file"
|
||||
multiple
|
||||
data-testid="upload-input"
|
||||
onChange={(event) => void handleFilesSelected(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
</div>
|
||||
<div className="upload-summary">
|
||||
<div>
|
||||
<strong>{files.length}</strong> files · <span>{sizeLabel}</span>
|
||||
</div>
|
||||
<div className={`upload-requirement${hasSkillFile ? ' ok' : ''}`}>
|
||||
SKILL.md {hasSkillFile ? 'found' : 'required'}
|
||||
</div>
|
||||
{files.length ? (
|
||||
<div className="upload-filelist">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={`${file.webkitRelativePath || file.name}:${file.size}`}
|
||||
className="upload-file"
|
||||
>
|
||||
<span>{file.webkitRelativePath || file.name}</span>
|
||||
<span>{formatBytes(file.size)}</span>
|
||||
<button
|
||||
className="upload-remove"
|
||||
type="button"
|
||||
onClick={() => handleRemoveFile(file)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="upload-muted">No files selected yet.</p>
|
||||
)}
|
||||
{files.length ? (
|
||||
<button className="btn" type="button" onClick={() => setFiles([])}>
|
||||
Clear selection
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="upload-notes">
|
||||
<strong>Checks</strong>
|
||||
<ul>
|
||||
<li>Include SKILL.md</li>
|
||||
<li>50 MB max per version</li>
|
||||
<li>Changelog required</li>
|
||||
<li>Valid semver version</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="upload-footer">
|
||||
<button className="btn btn-primary" type="submit" disabled={!validation.ready}>
|
||||
Publish
|
||||
</button>
|
||||
{!validation.ready ? (
|
||||
<div className="upload-validation">
|
||||
{validation.issues.map((issue) => (
|
||||
<div key={issue} className="upload-validation-item">
|
||||
{issue}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="upload-ready">Ready to publish.</div>
|
||||
)}
|
||||
{error ? <div className="stat upload-error">{error}</div> : null}
|
||||
{status ? <div className="stat">{status}</div> : null}
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
@@ -178,3 +382,15 @@ async function hashFile(file: File) {
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes)) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = bytes
|
||||
let unit = 0
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024
|
||||
unit += 1
|
||||
}
|
||||
return `${size.toFixed(size < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`
|
||||
}
|
||||
|
||||
+295
@@ -125,6 +125,13 @@ code {
|
||||
box-shadow: 0 10px 20px rgba(29, 26, 23, 0.12);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
|
||||
color: white;
|
||||
@@ -185,6 +192,283 @@ code {
|
||||
padding: 32px 24px 72px;
|
||||
}
|
||||
|
||||
.upload-shell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.upload-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.upload-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-deep);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.upload-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2.2rem, 3.5vw, 3rem);
|
||||
letter-spacing: -0.03em;
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.upload-subtitle {
|
||||
color: var(--ink-soft);
|
||||
max-width: 560px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-badge {
|
||||
background: linear-gradient(140deg, #ffddc9, #ffe9df);
|
||||
color: #9a3a24;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 12px 24px rgba(255, 107, 74, 0.18);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.upload-badge-sub {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.upload-card {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fff6f1 100%);
|
||||
border-radius: calc(var(--radius-lg) + 6px);
|
||||
border: 1px solid rgba(255, 107, 74, 0.18);
|
||||
padding: 28px;
|
||||
box-shadow: 0 24px 60px rgba(29, 26, 23, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(260px, 0.7fr);
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.upload-fields {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.upload-field {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upload-field span {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.upload-input {
|
||||
border: 1px solid rgba(29, 26, 23, 0.14);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.upload-input:focus {
|
||||
border-color: rgba(255, 107, 74, 0.6);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 107, 74, 0.2),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.upload-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.upload-side {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed rgba(255, 107, 74, 0.5);
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
background: radial-gradient(circle at top, #fff3ec 0%, #ffffff 65%);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dropzone:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 18px 30px rgba(255, 107, 74, 0.15);
|
||||
}
|
||||
|
||||
.dropzone.is-dragging {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 20px 40px rgba(255, 107, 74, 0.25);
|
||||
background: radial-gradient(circle at top, #ffe1d3 0%, #fff6f1 70%);
|
||||
}
|
||||
|
||||
.dropzone-icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 16px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.4rem;
|
||||
background: linear-gradient(160deg, #ff6b4a, #f08b5f);
|
||||
color: white;
|
||||
box-shadow: inset 0 0 0 3px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.dropzone-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-summary {
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(29, 26, 23, 0.08);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.upload-requirement {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 107, 74, 0.12);
|
||||
color: #9a3a24;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.upload-requirement.ok {
|
||||
background: rgba(43, 198, 164, 0.18);
|
||||
color: #1a6b5b;
|
||||
}
|
||||
|
||||
.upload-filelist {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.upload-filelist .upload-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.upload-file span:first-child {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upload-remove {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent-deep);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-remove:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.upload-more {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.upload-muted {
|
||||
color: var(--ink-soft);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-notes {
|
||||
background: #fff8f3;
|
||||
border: 1px solid rgba(255, 107, 74, 0.12);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.upload-notes ul {
|
||||
padding-left: 18px;
|
||||
margin: 8px 0 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.upload-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.upload-validation {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: #9a3a24;
|
||||
}
|
||||
|
||||
.upload-validation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-validation-item::before {
|
||||
content: "•";
|
||||
}
|
||||
|
||||
.upload-ready {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #1a6b5b;
|
||||
}
|
||||
|
||||
.upload-error {
|
||||
color: #b84a3a;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.8rem;
|
||||
@@ -259,6 +543,17 @@ code {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.upload-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.upload-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user