mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(skills): reuse shared upload field in skill import modal; logo-only Quartr icon (#5026)
* fix(skills): reuse shared upload field in skill import modal; logo-only Quartr icon - Replace the hand-rolled drop zone in the skill import modal with the shared ChipModalField type='file' control (same component the Knowledge Base and Help & Support modals use), so the upload zone is visually consistent. - Migrate the GitHub-URL and paste-content rows to ChipModalField so every field shares the canonical px-4 gutter and error rendering, and align the 'or' dividers to match. - Drop the monospace font on the paste textarea so its text matches the rest of the modal. - Quartr icon now renders the logo mark only (no wordmark) as a black mark on a white rounded tile. * fix(emcn): restore upload spinner via loading prop on ChipModalField file control Addresses review feedback — the shared file drop zone now accepts an optional loading prop that renders an animated spinner and blocks further picks while an async import is in flight, restoring the feedback the skill import modal lost when it migrated off its bespoke drop zone.
This commit is contained in:
+44
-114
@@ -1,13 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { Chip, ChipInput, ChipTextarea, Loader } from '@/components/emcn'
|
||||
import { Upload } from '@/components/emcn/icons'
|
||||
import { Chip, ChipInput, ChipModalField, ChipTextarea, Loader } from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { importSkillContract } from '@/lib/api/contracts'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
extractSkillFromZip,
|
||||
parseSkillMarkdown,
|
||||
@@ -33,10 +31,6 @@ function isAcceptedFile(file: File): boolean {
|
||||
}
|
||||
|
||||
export function SkillImport({ onImport }: SkillImportProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [dragCounter, setDragCounter] = useState(0)
|
||||
const isDragging = dragCounter > 0
|
||||
const [fileState, setFileState] = useState<ImportState>('idle')
|
||||
const [fileError, setFileError] = useState('')
|
||||
|
||||
@@ -84,39 +78,9 @@ export function SkillImport({ onImport }: SkillImportProps) {
|
||||
[onImport]
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) processFile(file)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
},
|
||||
[processFile]
|
||||
)
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => prev + 1)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => prev - 1)
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter(0)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
const handleFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
const file = files[0]
|
||||
if (file) processFile(file)
|
||||
},
|
||||
[processFile]
|
||||
@@ -159,55 +123,20 @@ export function SkillImport({ onImport }: SkillImportProps) {
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* File drop zone */}
|
||||
<div className='flex flex-col gap-[9px]'>
|
||||
<span className='pl-0.5 font-normal text-[var(--text-muted)] text-sm'>Upload File</span>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
disabled={fileState === 'loading'}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 transition-colors',
|
||||
'border-[var(--border-1)] bg-[var(--surface-5)] hover-hover:bg-[var(--surface-active)] dark:bg-[var(--surface-4)]',
|
||||
isDragging && 'border-[var(--text-muted)] bg-[var(--surface-active)]',
|
||||
fileState === 'loading' && 'pointer-events-none opacity-60'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='.md,.zip'
|
||||
onChange={handleFileChange}
|
||||
className='hidden'
|
||||
/>
|
||||
{fileState === 'loading' ? (
|
||||
<Loader className='size-[16px] text-[var(--text-tertiary)]' animate />
|
||||
) : (
|
||||
<Upload className='size-[16px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<div className='flex flex-col gap-0.5 text-center'>
|
||||
<span className='text-[var(--text-primary)] text-sm'>
|
||||
{isDragging ? 'Drop file here' : 'Drop file here or click to browse'}
|
||||
</span>
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>
|
||||
.md file with YAML frontmatter, or .zip containing a SKILL.md
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{fileError && <p className='text-[12px] text-[var(--text-error)]'>{fileError}</p>}
|
||||
</div>
|
||||
<ChipModalField
|
||||
type='file'
|
||||
title='Upload File'
|
||||
accept='.md,.zip'
|
||||
onChange={handleFiles}
|
||||
loading={fileState === 'loading'}
|
||||
label={fileState === 'loading' ? 'Importing…' : undefined}
|
||||
description='.md file with YAML frontmatter, or .zip containing a SKILL.md'
|
||||
error={fileError || undefined}
|
||||
/>
|
||||
|
||||
<ImportDivider />
|
||||
|
||||
{/* GitHub URL */}
|
||||
<div className='flex flex-col gap-[9px]'>
|
||||
<span className='pl-0.5 font-normal text-[var(--text-muted)] text-sm'>
|
||||
Import from GitHub
|
||||
</span>
|
||||
<ChipModalField type='custom' title='Import from GitHub' error={githubError || undefined}>
|
||||
<div className='flex gap-2'>
|
||||
<ChipInput
|
||||
placeholder='https://github.com/owner/repo/blob/main/SKILL.md'
|
||||
@@ -217,7 +146,7 @@ export function SkillImport({ onImport }: SkillImportProps) {
|
||||
if (githubError) setGithubError('')
|
||||
}}
|
||||
disabled={githubState === 'loading'}
|
||||
className='flex-1'
|
||||
className='min-w-0 flex-1'
|
||||
/>
|
||||
<Chip
|
||||
flush
|
||||
@@ -227,42 +156,43 @@ export function SkillImport({ onImport }: SkillImportProps) {
|
||||
{githubState === 'loading' ? <Loader className='size-[14px]' animate /> : 'Fetch'}
|
||||
</Chip>
|
||||
</div>
|
||||
{githubError && <p className='text-[12px] text-[var(--text-error)]'>{githubError}</p>}
|
||||
</div>
|
||||
</ChipModalField>
|
||||
|
||||
<ImportDivider />
|
||||
|
||||
{/* Paste content */}
|
||||
<div className='flex flex-col gap-[9px]'>
|
||||
<span className='pl-0.5 font-normal text-[var(--text-muted)] text-sm'>
|
||||
Paste SKILL.md Content
|
||||
</span>
|
||||
<ChipTextarea
|
||||
placeholder={
|
||||
'---\nname: my-skill\ndescription: What this skill does\n---\n\n# Instructions...'
|
||||
}
|
||||
value={pasteContent}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPasteContent(e.target.value)
|
||||
if (pasteError) setPasteError('')
|
||||
}}
|
||||
resizable
|
||||
className='min-h-[120px] font-mono leading-relaxed'
|
||||
/>
|
||||
{pasteError && <p className='text-[12px] text-[var(--text-error)]'>{pasteError}</p>}
|
||||
<div className='flex justify-end'>
|
||||
<Chip variant='primary' flush onClick={handlePasteImport} disabled={!pasteContent.trim()}>
|
||||
Import
|
||||
</Chip>
|
||||
<ChipModalField type='custom' title='Paste SKILL.md Content' error={pasteError || undefined}>
|
||||
<div className='flex flex-col gap-[9px]'>
|
||||
<ChipTextarea
|
||||
placeholder={
|
||||
'---\nname: my-skill\ndescription: What this skill does\n---\n\n# Instructions...'
|
||||
}
|
||||
value={pasteContent}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPasteContent(e.target.value)
|
||||
if (pasteError) setPasteError('')
|
||||
}}
|
||||
resizable
|
||||
className='min-h-[120px]'
|
||||
/>
|
||||
<div className='flex justify-end'>
|
||||
<Chip
|
||||
variant='primary'
|
||||
flush
|
||||
onClick={handlePasteImport}
|
||||
disabled={!pasteContent.trim()}
|
||||
>
|
||||
Import
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChipModalField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportDivider() {
|
||||
return (
|
||||
<div className='flex items-center gap-3 px-1'>
|
||||
<div className='flex items-center gap-3 px-2'>
|
||||
<div className='h-px flex-1 bg-[var(--border)]' />
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>or</span>
|
||||
<div className='h-px flex-1 bg-[var(--border)]' />
|
||||
|
||||
@@ -52,6 +52,7 @@ import { ChipTextarea } from '@/components/emcn/components/chip-textarea/chip-te
|
||||
import { Label } from '@/components/emcn/components/label/label'
|
||||
import { Modal, ModalContent } from '@/components/emcn/components/modal/modal'
|
||||
import { TagInput, type TagItem } from '@/components/emcn/components/tag-input/tag-input'
|
||||
import { Loader } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
|
||||
@@ -377,6 +378,14 @@ interface ChipModalFileFieldProps extends ChipModalFieldBaseProps {
|
||||
* for a single-line zone.
|
||||
*/
|
||||
description?: React.ReactNode
|
||||
/**
|
||||
* Renders a spinner inside the drop zone and blocks further picks while an
|
||||
* async import/upload is in flight. Use for slow selections (zip extraction,
|
||||
* remote fetches) where the zone would otherwise look idle. Pair with a
|
||||
* `label` such as `'Importing…'` for an explicit status line.
|
||||
* @default false
|
||||
*/
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export interface ChipModalEmailsFieldProps extends ChipModalFieldBaseProps {
|
||||
@@ -692,6 +701,7 @@ function ChipModalFileControl({
|
||||
multiple = false,
|
||||
label = 'Drop files here or click to browse',
|
||||
description,
|
||||
loading = false,
|
||||
disabled,
|
||||
id,
|
||||
'aria-required': ariaRequired,
|
||||
@@ -700,6 +710,7 @@ function ChipModalFileControl({
|
||||
}: ChipModalFileFieldProps & { id: string } & React.AriaAttributes) {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = React.useState(false)
|
||||
const isInteractive = !disabled && !loading
|
||||
|
||||
const emitFiles = React.useCallback(
|
||||
(files: FileList | null) => {
|
||||
@@ -713,7 +724,8 @@ function ChipModalFileControl({
|
||||
<button
|
||||
type='button'
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
disabled={!isInteractive}
|
||||
aria-busy={loading || undefined}
|
||||
aria-required={ariaRequired}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedby}
|
||||
@@ -721,7 +733,7 @@ function ChipModalFileControl({
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!disabled) setIsDragging(true)
|
||||
if (isInteractive) setIsDragging(true)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -736,7 +748,7 @@ function ChipModalFileControl({
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsDragging(false)
|
||||
if (!disabled) emitFiles(event.dataTransfer.files)
|
||||
if (isInteractive) emitFiles(event.dataTransfer.files)
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full flex-col items-center justify-center gap-0.5 rounded-lg border border-[var(--border-1)] border-dashed bg-[var(--surface-5)] px-2 py-2.5 text-center outline-none transition-colors hover-hover:border-[var(--surface-7)] disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[var(--surface-4)]',
|
||||
@@ -748,13 +760,14 @@ function ChipModalFileControl({
|
||||
type='file'
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
disabled={disabled}
|
||||
disabled={!isInteractive}
|
||||
className='hidden'
|
||||
onChange={(event) => {
|
||||
emitFiles(event.target.files)
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
{loading ? <Loader animate className='size-[14px] text-[var(--text-tertiary)]' /> : null}
|
||||
<span className='text-[var(--text-primary)] text-caption'>
|
||||
{isDragging ? 'Drop files here' : label}
|
||||
</span>
|
||||
|
||||
@@ -3707,44 +3707,23 @@ export function QdrantIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function QuartrIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 151 40' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='M11.5483 29.9995C11.5483 35.5222 19.8031 39.9995 26.1809 39.9995C29.574 39.9995 32.6254 38.7323 34.7384 36.7144L26.1809 29.9995H11.5483Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M0 29.9996C0 35.5222 5.17047 39.9995 11.5483 39.9995V19.9998C5.17047 19.9998 0 24.4768 0 29.9996Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M26.1808 9.99989V27.5075H37.7292V10.0249C37.7173 4.46985 32.552 -0.0135841 26.1808 2.98331e-05H11.4362V0.00114226C5.11012 0.0536532 0 4.50986 0 9.99989V19.9998H11.5483V9.99989H26.1808'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path d='M28.1898 29.3275L40.7827 38.9295V29.3275H28.1898Z' fill='currentColor' />
|
||||
<path
|
||||
d='M88.7938 25.1089H88.7527C87.927 29.0309 85.4089 30.806 82.0238 30.806C77.0289 30.806 75.2949 27.5033 75.2949 23.3752V11.0324H78.8865V22.4671C78.8865 25.1089 80.0423 27.5033 83.5102 27.5033C86.8123 27.5033 88.7938 25.6872 88.7938 22.4671V11.0324H92.3854V30.3106H88.7938V25.1089Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M107.351 23.1277V22.2193H101.613C99.1774 22.2193 97.8563 23.1277 97.8563 24.82C97.8563 26.3063 98.8062 27.7095 101.613 27.7095C105.163 27.7095 107.351 25.7697 107.351 23.1277ZM107.351 25.3982H107.31C106.443 28.948 103.842 30.7235 100.499 30.7235C96.7419 30.7235 94.3067 28.907 94.3067 25.2742C94.3067 21.8892 96.4529 19.371 101.118 19.371H107.351V18.3389C107.351 15.3254 106.196 13.6742 103.14 13.6742C100.375 13.6742 98.8473 14.9538 98.3108 18.1324L94.8843 17.6371C95.5447 13.1375 98.1869 10.6193 103.264 10.6193C108.631 10.6193 110.943 13.3438 110.943 18.2976V30.3106H107.351V25.3982'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M112.909 30.3106V11.0324H116.5V16.9767H116.583C117.367 12.477 119.184 11.0324 122.28 11.0324H123.477V14.995H121.743C118.111 14.995 116.5 16.3988 116.5 20.1141V30.3106H112.909'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M128.24 14.1166L125.391 15.0444V11.0205H128.24V5.69549H131.832V11.0205H137.982V15.0146L131.832 14.1166V24.0654C131.832 26.2122 132.657 27.3266 134.804 27.3266C135.836 27.3266 136.703 27.1203 137.735 26.8313L138.189 30.1339C136.868 30.5465 135.836 30.7944 134.06 30.7944C130.015 30.7944 128.24 28.1524 128.24 24.5609V14.1166'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M140.129 30.3106V11.0324H143.72V16.9767H143.803C144.587 12.477 146.403 11.0324 149.499 11.0324H150.697V14.995H148.963C145.33 14.995 143.72 16.3988 143.72 20.1141V30.3106H140.129'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M70.4206 19.1212C70.4206 14.0209 66.7718 10.045 61.8913 10.045C57.0108 10.045 53.0603 14.0209 53.0603 19.1212C53.0603 24.5028 56.9397 27.7175 61.8913 27.7175C66.8429 27.7175 70.4206 24.5028 70.4206 19.1212V19.1212ZM49.7827 19.1615C49.7827 11.9726 55.2655 6.67151 61.8913 6.67151C68.553 6.67151 73.6979 11.9726 73.6979 19.1615C73.6979 23.4184 71.6333 26.3923 68.9972 28.6411L72.0609 34.2237V34.304H68.4629L66.3614 30.2878C65.1145 30.7696 63.423 31.051 61.8913 31.051C55.1227 31.051 49.7827 26.3098 49.7827 19.1615'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<svg {...props} viewBox='0 0 32 32' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<rect width='32' height='32' rx='6' fill='white' />
|
||||
<g transform='translate(6 6.19) scale(0.16624)'>
|
||||
<path
|
||||
d='M34.0674 88.4993C34.0674 104.791 58.4191 117.999 77.2336 117.999C87.2434 117.999 96.2453 114.261 102.478 108.308L77.2336 88.4993H34.0674Z'
|
||||
fill='black'
|
||||
/>
|
||||
<path
|
||||
d='M0 88.4993C0 104.791 15.253 117.999 34.0677 117.999V58.9997C15.253 58.9997 0 72.2071 0 88.4993Z'
|
||||
fill='black'
|
||||
/>
|
||||
<path
|
||||
d='M77.2339 29.4999V81.1477H111.302V29.5736C111.267 13.1861 96.0292 -0.0400894 77.2339 7.20269e-05H33.737V0.00335371C15.075 0.158262 0 13.3042 0 29.4999V58.9997H34.0677V29.4999H77.2339'
|
||||
fill='black'
|
||||
/>
|
||||
<path d='M83.1602 86.5171L120.31 114.843V86.5171H83.1602Z' fill='black' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user