sparkdesign 0.0.1 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -4
- package/dist/commands/add.js +93 -0
- package/dist/commands/diff.js +54 -0
- package/dist/commands/init.js +96 -0
- package/dist/commands/list.js +25 -0
- package/dist/index.js +37 -0
- package/dist/utils/config.js +53 -0
- package/dist/utils/registry.js +34 -0
- package/dist/utils/tokens.js +176 -0
- package/dist/utils/transform.js +19 -0
- package/package.json +33 -10
- package/registry/__tests__/basic/button.test.tsx +333 -0
- package/registry/__tests__/chat/markdown.test.tsx +387 -0
- package/registry/__tests__/chat/thinking-indicator.test.tsx +244 -0
- package/registry/__tests__/chat/tool-invocation-card.test.tsx +346 -0
- package/registry/basic/alert-dialog.tsx +180 -0
- package/registry/basic/avatar.tsx +120 -0
- package/registry/basic/button.tsx +100 -0
- package/registry/basic/collapse.tsx +94 -0
- package/registry/basic/collapsible-card.tsx +230 -0
- package/registry/basic/collapsible.tsx +21 -0
- package/registry/basic/dropdown-menu.tsx +254 -0
- package/registry/basic/icon-button.tsx +66 -0
- package/registry/basic/icons-inline.tsx +206 -0
- package/registry/basic/kbd.tsx +50 -0
- package/registry/basic/option-list.tsx +125 -0
- package/registry/basic/pagination.tsx +132 -0
- package/registry/basic/progress.tsx +42 -0
- package/registry/basic/radio-group.tsx +69 -0
- package/registry/basic/resizable.tsx +67 -0
- package/registry/basic/scrollbar.tsx +114 -0
- package/registry/basic/select.tsx +177 -0
- package/registry/basic/shimmering-text.tsx +115 -0
- package/registry/basic/sidebar-menu.tsx +177 -0
- package/registry/basic/skeleton.tsx +33 -0
- package/registry/basic/slider.tsx +55 -0
- package/registry/basic/sonner.tsx +104 -0
- package/registry/basic/spinner.tsx +17 -0
- package/registry/basic/switch.tsx +49 -0
- package/registry/basic/table.tsx +117 -0
- package/registry/basic/tabs.tsx +85 -0
- package/registry/basic/tag.tsx +161 -0
- package/registry/basic/theme-from-document.ts +10 -0
- package/registry/basic/toggle.tsx +223 -0
- package/registry/basic/tooltip.tsx +80 -0
- package/registry/basic/typography.tsx +201 -0
- package/registry/chat/ask-user-part.tsx +70 -0
- package/registry/chat/browser-action-part.tsx +166 -0
- package/registry/chat/chat-input/chat-input-folder-selector.tsx +185 -0
- package/registry/chat/chat-input/chat-input-model-switcher.tsx +131 -0
- package/registry/chat/chat-input/chat-input-textarea.tsx +67 -0
- package/registry/chat/chat-input/compound.tsx +334 -0
- package/registry/chat/chat-input/context.tsx +189 -0
- package/registry/chat/chat-input/folder-permission-dialog.tsx +61 -0
- package/registry/chat/chat-input/index.tsx +123 -0
- package/registry/chat/chat-input/types.ts +77 -0
- package/registry/chat/chat-input/useAutoResizeTextarea.ts +20 -0
- package/registry/chat/code-block-part.tsx +151 -0
- package/registry/chat/file-attachment.tsx +44 -0
- package/registry/chat/file-card.tsx +68 -0
- package/registry/chat/file-review-part.tsx +259 -0
- package/registry/chat/folder-button.tsx +169 -0
- package/registry/chat/generated-images-grid.tsx +56 -0
- package/registry/chat/generation-status-bar.tsx +72 -0
- package/registry/chat/hint-banner.tsx +165 -0
- package/registry/chat/image-attachment.tsx +166 -0
- package/registry/chat/image-generating.tsx +281 -0
- package/registry/chat/markdown.tsx +146 -0
- package/registry/chat/mermaid-part.tsx +90 -0
- package/registry/chat/permission-card.tsx +178 -0
- package/registry/chat/plan-part.tsx +168 -0
- package/registry/chat/queue-indicator.tsx +234 -0
- package/registry/chat/reasoning-step/compound.tsx +336 -0
- package/registry/chat/reasoning-step/context.tsx +114 -0
- package/registry/chat/reasoning-step/index.tsx +45 -0
- package/registry/chat/reasoning-step/types.ts +109 -0
- package/registry/chat/related-prompts.tsx +91 -0
- package/registry/chat/response/compound.tsx +210 -0
- package/registry/chat/response/context.tsx +200 -0
- package/registry/chat/response/index.tsx +87 -0
- package/registry/chat/response/types.ts +123 -0
- package/registry/chat/send-button.tsx +94 -0
- package/registry/chat/streaming-markdown-block.tsx +111 -0
- package/registry/chat/task-part.tsx +109 -0
- package/registry/chat/terminal-code-block-part.tsx +69 -0
- package/registry/chat/thinking-indicator.tsx +91 -0
- package/registry/chat/tool-invocation-card.tsx +132 -0
- package/registry/chat/user-message.tsx +38 -0
- package/registry/chat/user-question/UserQuestionCard.tsx +198 -0
- package/registry/chat/user-question/UserQuestionFooter.tsx +66 -0
- package/registry/chat/user-question/UserQuestionHeader.tsx +64 -0
- package/registry/chat/user-question/compound.tsx +324 -0
- package/registry/chat/user-question/context.tsx +456 -0
- package/registry/chat/user-question/index.tsx +95 -0
- package/registry/chat/user-question/types.ts +61 -0
- package/registry/chat/user-question/useUserQuestionKeyboard.ts +126 -0
- package/registry/chat/user-question/useUserQuestionState.ts +165 -0
- package/registry/chat/user-question-answer.tsx +62 -0
- package/registry/lib/file-icon-maps.ts +150 -0
- package/registry/lib/use-mermaid-render.ts +76 -0
- package/registry/lib/utils.ts +6 -0
- package/registry/meta.json +1 -0
- package/registry/tokens/index.css +31 -0
- package/registry/tokens/scale/computed.css +103 -0
- package/registry/tokens/scale/config.css +110 -0
- package/registry/tokens/scale/index.css +30 -0
- package/registry/tokens/scale/presets/compact.css +30 -0
- package/registry/tokens/scale/presets/dense.css +64 -0
- package/registry/tokens/scale/presets/sharp.css +40 -0
- package/registry/tokens/scale/presets/soft.css +16 -0
- package/registry/tokens/scale.css +13 -0
- package/registry/tokens/scrollbar-utility.css +35 -0
- package/registry/tokens/theme.css +633 -0
- package/registry/tokens/themes/dark-parchment.css +132 -0
- package/registry/tokens/themes/dark-qoder.css +132 -0
- package/registry/tokens/themes/light-parchment.css +123 -0
- package/registry/tokens/themes/light-qoder.css +131 -0
- package/index.js +0 -5
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { forwardRef } from 'react'
|
|
2
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
import { FileLine } from '../basic/icons-inline'
|
|
5
|
+
|
|
6
|
+
const ICON_CLASS = 'w-5 h-5 text-text-secondary'
|
|
7
|
+
|
|
8
|
+
export interface FileCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
|
9
|
+
filename: string
|
|
10
|
+
iconFilename?: string
|
|
11
|
+
size?: string
|
|
12
|
+
icon?: ReactNode
|
|
13
|
+
getFileIcon?: (filename: string, className?: string) => ReactNode
|
|
14
|
+
onClick?: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const FileCard = forwardRef<HTMLDivElement, FileCardProps>(
|
|
18
|
+
({ filename, iconFilename, size, icon, getFileIcon, onClick, className, ...props }, ref) => {
|
|
19
|
+
const iconLookup = iconFilename ?? filename
|
|
20
|
+
const resolvedIcon: ReactNode =
|
|
21
|
+
icon ?? (getFileIcon ? getFileIcon(iconLookup, ICON_CLASS) : <FileLine className={ICON_CLASS} />)
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
ref={ref}
|
|
26
|
+
role={onClick ? 'button' : undefined}
|
|
27
|
+
tabIndex={onClick ? 0 : undefined}
|
|
28
|
+
onClick={onClick}
|
|
29
|
+
onKeyDown={
|
|
30
|
+
onClick
|
|
31
|
+
? (e) => {
|
|
32
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
33
|
+
e.preventDefault()
|
|
34
|
+
onClick()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
: undefined
|
|
38
|
+
}
|
|
39
|
+
className={cn(
|
|
40
|
+
'flex flex-row items-center gap-4 w-full max-w-96',
|
|
41
|
+
'py-5 px-5 rounded-lg',
|
|
42
|
+
'bg-bg-base border border-border-tertiary',
|
|
43
|
+
'text-sm leading-sm',
|
|
44
|
+
'transition-colors cursor-pointer hover:border-border-secondary',
|
|
45
|
+
className
|
|
46
|
+
)}
|
|
47
|
+
{...props}
|
|
48
|
+
>
|
|
49
|
+
<div
|
|
50
|
+
className={cn(
|
|
51
|
+
'flex items-center justify-center w-10 h-10 shrink-0 rounded-md',
|
|
52
|
+
'bg-fill-tertiary'
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{resolvedIcon}
|
|
56
|
+
</div>
|
|
57
|
+
<div className="flex flex-row items-center gap-3 min-w-0">
|
|
58
|
+
<span className="font-medium text-text truncate">{filename}</span>
|
|
59
|
+
{size != null && size !== '' && (
|
|
60
|
+
<span className="shrink-0 text-text-tertiary">({size})</span>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
FileCard.displayName = 'FileCard'
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
import { CollapsibleCard } from '../basic/collapsible-card'
|
|
4
|
+
import { Button } from '../basic/button'
|
|
5
|
+
import { RequestChangesLine, CheckLine, CloseLine } from '../basic/icons-inline'
|
|
6
|
+
|
|
7
|
+
const ICON_CLASS = 'w-[var(--font-size-sm)] h-[var(--font-size-sm)] shrink-0'
|
|
8
|
+
/** Seti 图标:固定 16px,与 QodeChatPanel 的 w-4 h-4 一致 */
|
|
9
|
+
const SETI_ICON_CLASS =
|
|
10
|
+
"font-['Seti'] shrink-0 inline-flex items-center justify-center w-4 h-4 text-[16px] leading-[16px]"
|
|
11
|
+
|
|
12
|
+
// 与 QodeChatPanel FileReviewPart.jsx 内联映射完全一致(qoder-seti.woff)
|
|
13
|
+
const SETI_CHAR_MAP: Record<string, string> = {
|
|
14
|
+
javascript: '\ue054',
|
|
15
|
+
typescript: '\ue099',
|
|
16
|
+
react: '\ue07d',
|
|
17
|
+
json: '\ue059',
|
|
18
|
+
html: '\ue048',
|
|
19
|
+
css: '\ue01d',
|
|
20
|
+
python: '\ue07b',
|
|
21
|
+
java: '\ue050',
|
|
22
|
+
go: '\ue039',
|
|
23
|
+
rust: '\ue082',
|
|
24
|
+
php: '\ue070',
|
|
25
|
+
ruby: '\ue081',
|
|
26
|
+
vue: '\ue09d',
|
|
27
|
+
svelte: '\ue090',
|
|
28
|
+
markdown: '\ue060',
|
|
29
|
+
yaml: '\ue0a7',
|
|
30
|
+
xml: '\ue0a5',
|
|
31
|
+
shell: '\ue089',
|
|
32
|
+
sass: '\ue084',
|
|
33
|
+
settings: '\ue019',
|
|
34
|
+
default: '\ue023',
|
|
35
|
+
}
|
|
36
|
+
const SETI_COLOR_MAP: Record<string, string> = {
|
|
37
|
+
javascript: '#F8CA3E',
|
|
38
|
+
typescript: '#1E8DEE',
|
|
39
|
+
react: '#1E8DEE',
|
|
40
|
+
json: '#F8CA3E',
|
|
41
|
+
html: '#1E8DEE',
|
|
42
|
+
css: '#1E8DEE',
|
|
43
|
+
python: '#1E8DEE',
|
|
44
|
+
java: '#E84654',
|
|
45
|
+
go: '#1E8DEE',
|
|
46
|
+
rust: '#6A89E6',
|
|
47
|
+
php: '#AE4EE2',
|
|
48
|
+
ruby: '#E84654',
|
|
49
|
+
vue: '#4EBD35',
|
|
50
|
+
svelte: '#E84654',
|
|
51
|
+
markdown: '#1E8DEE',
|
|
52
|
+
yaml: '#AE4EE2',
|
|
53
|
+
xml: '#F18745',
|
|
54
|
+
shell: '#4EBD35',
|
|
55
|
+
sass: '#E84654',
|
|
56
|
+
settings: '#6A89E6',
|
|
57
|
+
default: '#6A89E6',
|
|
58
|
+
}
|
|
59
|
+
const FILE_TYPE_MAP: Record<string, string> = {
|
|
60
|
+
js: 'javascript',
|
|
61
|
+
jsx: 'react',
|
|
62
|
+
ts: 'typescript',
|
|
63
|
+
tsx: 'typescript',
|
|
64
|
+
json: 'json',
|
|
65
|
+
html: 'html',
|
|
66
|
+
css: 'css',
|
|
67
|
+
scss: 'sass',
|
|
68
|
+
sass: 'sass',
|
|
69
|
+
less: 'css',
|
|
70
|
+
py: 'python',
|
|
71
|
+
java: 'java',
|
|
72
|
+
go: 'go',
|
|
73
|
+
rs: 'rust',
|
|
74
|
+
php: 'php',
|
|
75
|
+
rb: 'ruby',
|
|
76
|
+
vue: 'vue',
|
|
77
|
+
svelte: 'svelte',
|
|
78
|
+
md: 'markdown',
|
|
79
|
+
yml: 'yaml',
|
|
80
|
+
yaml: 'yaml',
|
|
81
|
+
xml: 'xml',
|
|
82
|
+
sh: 'shell',
|
|
83
|
+
bash: 'shell',
|
|
84
|
+
zsh: 'shell',
|
|
85
|
+
config: 'settings',
|
|
86
|
+
conf: 'settings',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getSetiIconType(fileType?: string, filename?: string): string {
|
|
90
|
+
let iconType: string | null = null
|
|
91
|
+
if (filename && typeof filename === 'string' && filename.trim()) {
|
|
92
|
+
const lastDotIndex = filename.lastIndexOf('.')
|
|
93
|
+
if (lastDotIndex > 0 && lastDotIndex < filename.length - 1) {
|
|
94
|
+
const ext = filename.substring(lastDotIndex + 1).toLowerCase().trim()
|
|
95
|
+
if (ext && FILE_TYPE_MAP[ext]) iconType = FILE_TYPE_MAP[ext]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!iconType && fileType && typeof fileType === 'string' && fileType.trim()) {
|
|
99
|
+
const lowerFileType = fileType.toLowerCase().trim()
|
|
100
|
+
if (FILE_TYPE_MAP[lowerFileType]) iconType = FILE_TYPE_MAP[lowerFileType]
|
|
101
|
+
else if (SETI_CHAR_MAP[lowerFileType]) iconType = lowerFileType
|
|
102
|
+
}
|
|
103
|
+
return iconType || 'default'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getSetiIcon(fileType?: string, filename?: string): string {
|
|
107
|
+
const iconType = getSetiIconType(fileType, filename)
|
|
108
|
+
return SETI_CHAR_MAP[iconType] ?? SETI_CHAR_MAP.default
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getSetiIconColor(fileType?: string, filename?: string): string {
|
|
112
|
+
const iconType = getSetiIconType(fileType, filename)
|
|
113
|
+
return SETI_COLOR_MAP[iconType] ?? SETI_COLOR_MAP.default
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface FileReviewFile {
|
|
117
|
+
filename: string
|
|
118
|
+
fileType?: string
|
|
119
|
+
additions?: number
|
|
120
|
+
deletions?: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface FileReviewPartProps {
|
|
124
|
+
files?: FileReviewFile[]
|
|
125
|
+
defaultExpanded?: boolean
|
|
126
|
+
className?: string
|
|
127
|
+
/** 卡片标题区图标,未传则用内联 SVG */
|
|
128
|
+
headerIcon?: ReactNode
|
|
129
|
+
/** 接受按钮图标,未传则用内联 SVG */
|
|
130
|
+
acceptIcon?: ReactNode
|
|
131
|
+
/** 拒绝按钮图标,未传则用内联 SVG */
|
|
132
|
+
rejectIcon?: ReactNode
|
|
133
|
+
/** 按文件类型/文件名渲染图标;未传则用 Seti 字体字符(需加载 qoder-seti.woff) */
|
|
134
|
+
getFileIcon?: (fileType?: string, filename?: string) => ReactNode
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function FileReviewPart({
|
|
138
|
+
files = [],
|
|
139
|
+
defaultExpanded = true,
|
|
140
|
+
className,
|
|
141
|
+
headerIcon,
|
|
142
|
+
acceptIcon,
|
|
143
|
+
rejectIcon,
|
|
144
|
+
getFileIcon,
|
|
145
|
+
}: FileReviewPartProps) {
|
|
146
|
+
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
|
147
|
+
const defaultHeaderIcon = <RequestChangesLine className={cn(ICON_CLASS, 'text-text-secondary')} />
|
|
148
|
+
const actionIconSize = 'w-[var(--font-size-sm)] h-[var(--font-size-sm)]'
|
|
149
|
+
const defaultAcceptIcon = <CheckLine className={actionIconSize} />
|
|
150
|
+
const defaultRejectIcon = <CloseLine className={actionIconSize} />
|
|
151
|
+
const actionBtnClass =
|
|
152
|
+
'flex items-center justify-center shrink-0 rounded cursor-pointer transition-colors duration-200 text-text-secondary hover:text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<CollapsibleCard
|
|
156
|
+
headerIcon={headerIcon ?? defaultHeaderIcon}
|
|
157
|
+
headerTitle={
|
|
158
|
+
<span className="font-normal">
|
|
159
|
+
{files.length} {files.length === 1 ? 'File' : 'Files'}
|
|
160
|
+
</span>
|
|
161
|
+
}
|
|
162
|
+
headerRight={
|
|
163
|
+
<div className="flex items-center gap-1.5">
|
|
164
|
+
<Button variant="ghost" size="sm" onClick={(e) => e.stopPropagation()}>
|
|
165
|
+
Undo All
|
|
166
|
+
</Button>
|
|
167
|
+
<Button variant="ghost" size="sm" onClick={(e) => e.stopPropagation()}>
|
|
168
|
+
Keep All
|
|
169
|
+
</Button>
|
|
170
|
+
<Button variant="secondary" size="sm" onClick={(e) => e.stopPropagation()}>
|
|
171
|
+
Review
|
|
172
|
+
</Button>
|
|
173
|
+
</div>
|
|
174
|
+
}
|
|
175
|
+
defaultExpanded={defaultExpanded}
|
|
176
|
+
contentPadding="var(--spacing-1\.5) var(--spacing-1) var(--spacing-1\.5)"
|
|
177
|
+
className={className}
|
|
178
|
+
>
|
|
179
|
+
<div className="flex flex-col gap-0.5">
|
|
180
|
+
{files.map((file, index) => {
|
|
181
|
+
const isHovered = hoveredIndex === index
|
|
182
|
+
const add = file.additions != null && Number.isFinite(file.additions) ? file.additions : null
|
|
183
|
+
const del = file.deletions != null && Number.isFinite(file.deletions) ? file.deletions : null
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div
|
|
187
|
+
key={index}
|
|
188
|
+
className={cn(
|
|
189
|
+
'flex items-center gap-1 px-1 py-1 rounded-sm cursor-pointer transition-colors duration-200 relative h-6',
|
|
190
|
+
isHovered ? 'bg-fill-secondary' : 'bg-transparent'
|
|
191
|
+
)}
|
|
192
|
+
onMouseEnter={() => setHoveredIndex(index)}
|
|
193
|
+
onMouseLeave={() => setHoveredIndex(null)}
|
|
194
|
+
>
|
|
195
|
+
{getFileIcon ? (
|
|
196
|
+
<span className={cn('shrink-0', ICON_CLASS)}>{getFileIcon(file.fileType, file.filename)}</span>
|
|
197
|
+
) : (
|
|
198
|
+
<span
|
|
199
|
+
className={cn('shrink-0', SETI_ICON_CLASS)}
|
|
200
|
+
style={{ color: getSetiIconColor(file.fileType, file.filename) }}
|
|
201
|
+
aria-hidden
|
|
202
|
+
>
|
|
203
|
+
{getSetiIcon(file.fileType, file.filename)}
|
|
204
|
+
</span>
|
|
205
|
+
)}
|
|
206
|
+
<span
|
|
207
|
+
className={cn(
|
|
208
|
+
'flex-1 text-xs font-medium truncate pr-12',
|
|
209
|
+
isHovered ? 'text-text' : 'text-text-tertiary'
|
|
210
|
+
)}
|
|
211
|
+
>
|
|
212
|
+
{file.filename}
|
|
213
|
+
</span>
|
|
214
|
+
{/* 默认展示 diff 统计,hover 时淡出 */}
|
|
215
|
+
<div
|
|
216
|
+
className={cn(
|
|
217
|
+
'absolute right-1 flex items-center gap-0.5 text-xs font-medium shrink-0 transition-opacity duration-200',
|
|
218
|
+
isHovered ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
|
219
|
+
)}
|
|
220
|
+
>
|
|
221
|
+
{add != null && <span className="text-success">+{add}</span>}
|
|
222
|
+
{del != null && <span className="text-error">-{del}</span>}
|
|
223
|
+
</div>
|
|
224
|
+
{/* Hover 时显示的对号/错号按钮,始终挂载用 opacity 控制显隐;用原生 button 保证图标可见 */}
|
|
225
|
+
<div
|
|
226
|
+
className={cn(
|
|
227
|
+
'flex items-center gap-0.5 shrink-0 absolute right-1 transition-opacity duration-200',
|
|
228
|
+
isHovered ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
|
|
229
|
+
)}
|
|
230
|
+
onClick={(e) => e.stopPropagation()}
|
|
231
|
+
>
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
className={cn(actionBtnClass, 'h-6 min-w-6 [&_svg]:w-[var(--font-size-sm)] [&_svg]:h-[var(--font-size-sm)]')}
|
|
235
|
+
title="Reject"
|
|
236
|
+
aria-label="Reject"
|
|
237
|
+
onClick={(e) => e.stopPropagation()}
|
|
238
|
+
>
|
|
239
|
+
{rejectIcon ?? defaultRejectIcon}
|
|
240
|
+
</button>
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
className={cn(actionBtnClass, 'h-6 min-w-6 [&_svg]:w-[var(--font-size-sm)] [&_svg]:h-[var(--font-size-sm)]')}
|
|
244
|
+
title="Accept"
|
|
245
|
+
aria-label="Accept"
|
|
246
|
+
onClick={(e) => e.stopPropagation()}
|
|
247
|
+
>
|
|
248
|
+
{acceptIcon ?? defaultAcceptIcon}
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
})}
|
|
254
|
+
</div>
|
|
255
|
+
</CollapsibleCard>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
FileReviewPart.displayName = 'FileReviewPart'
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { forwardRef, useState } from 'react'
|
|
2
|
+
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
import { motion } from 'framer-motion'
|
|
6
|
+
import {
|
|
7
|
+
FolderLine,
|
|
8
|
+
FolderFillLine,
|
|
9
|
+
ArrowRightLine,
|
|
10
|
+
CloseLine,
|
|
11
|
+
} from '../basic/icons-inline'
|
|
12
|
+
|
|
13
|
+
const folderButtonVariants = cva(
|
|
14
|
+
'inline-flex items-center justify-center gap-2 font-medium transition-colors duration-200 focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 group text-sm leading-sm',
|
|
15
|
+
{
|
|
16
|
+
variants: {
|
|
17
|
+
size: {
|
|
18
|
+
sm: 'h-7 px-2 text-xs leading-xs',
|
|
19
|
+
md: 'h-9 px-3 text-sm leading-sm',
|
|
20
|
+
lg: 'h-11 px-4 text-base leading-base',
|
|
21
|
+
},
|
|
22
|
+
rounded: {
|
|
23
|
+
square: 'rounded',
|
|
24
|
+
pill: 'rounded-full',
|
|
25
|
+
},
|
|
26
|
+
selected: {
|
|
27
|
+
true: 'bg-info-bg text-info',
|
|
28
|
+
false: 'bg-fill-tertiary text-text hover:bg-fill-secondary',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
defaultVariants: {
|
|
32
|
+
size: 'md',
|
|
33
|
+
rounded: 'square',
|
|
34
|
+
selected: false,
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const iconClass = 'w-4 h-4'
|
|
40
|
+
|
|
41
|
+
export interface FolderButtonProps
|
|
42
|
+
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
|
43
|
+
VariantProps<typeof folderButtonVariants> {
|
|
44
|
+
label: string
|
|
45
|
+
selected?: boolean
|
|
46
|
+
disabled?: boolean
|
|
47
|
+
multipleFolders?: boolean
|
|
48
|
+
onClear?: () => void
|
|
49
|
+
folderIcon?: ReactNode
|
|
50
|
+
folderFillIcon?: ReactNode
|
|
51
|
+
arrowRightIcon?: ReactNode
|
|
52
|
+
closeIcon?: ReactNode
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const FolderButton = forwardRef<HTMLButtonElement, FolderButtonProps>(
|
|
56
|
+
(
|
|
57
|
+
{
|
|
58
|
+
label,
|
|
59
|
+
selected = false,
|
|
60
|
+
disabled = false,
|
|
61
|
+
multipleFolders = false,
|
|
62
|
+
onClear,
|
|
63
|
+
size = 'md',
|
|
64
|
+
rounded = 'square',
|
|
65
|
+
className,
|
|
66
|
+
onMouseEnter: onMouseEnterProp,
|
|
67
|
+
onMouseLeave: onMouseLeaveProp,
|
|
68
|
+
folderIcon,
|
|
69
|
+
folderFillIcon,
|
|
70
|
+
arrowRightIcon,
|
|
71
|
+
closeIcon,
|
|
72
|
+
...props
|
|
73
|
+
},
|
|
74
|
+
ref
|
|
75
|
+
) => {
|
|
76
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
77
|
+
const showClose = selected && !disabled && !multipleFolders && typeof onClear === 'function'
|
|
78
|
+
|
|
79
|
+
const baseStyles = cn(
|
|
80
|
+
folderButtonVariants({ size, rounded, selected, className }),
|
|
81
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
82
|
+
!disabled && 'cursor-pointer'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
86
|
+
if (showClose) setIsHovered(true)
|
|
87
|
+
onMouseEnterProp?.(e)
|
|
88
|
+
}
|
|
89
|
+
const handleMouseLeave = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
90
|
+
if (showClose) setIsHovered(false)
|
|
91
|
+
onMouseLeaveProp?.(e)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const defaultFolder = folderIcon ?? <FolderLine className={iconClass} />
|
|
95
|
+
const defaultFolderFill = folderFillIcon ?? <FolderFillLine className={iconClass} />
|
|
96
|
+
const defaultArrow = arrowRightIcon ?? <ArrowRightLine className={iconClass} />
|
|
97
|
+
const defaultClose = closeIcon ?? <CloseLine className={iconClass} />
|
|
98
|
+
|
|
99
|
+
const content = (
|
|
100
|
+
<>
|
|
101
|
+
<span className="w-4 h-4 flex items-center justify-center flex-shrink-0 relative text-current">
|
|
102
|
+
{selected ? defaultFolderFill : defaultFolder}
|
|
103
|
+
</span>
|
|
104
|
+
|
|
105
|
+
<span>{label}</span>
|
|
106
|
+
|
|
107
|
+
{multipleFolders && (
|
|
108
|
+
<span className="w-4 h-4 flex items-center justify-center flex-shrink-0">
|
|
109
|
+
{defaultArrow}
|
|
110
|
+
</span>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{showClose ? (
|
|
114
|
+
<motion.span
|
|
115
|
+
role="button"
|
|
116
|
+
tabIndex={0}
|
|
117
|
+
aria-label="清空文件夹选择"
|
|
118
|
+
className="flex items-center justify-center flex-shrink-0 text-info hover:text-info-hover cursor-pointer overflow-hidden"
|
|
119
|
+
initial={false}
|
|
120
|
+
animate={{
|
|
121
|
+
width: isHovered ? 20 : 0,
|
|
122
|
+
opacity: isHovered ? 1 : 0,
|
|
123
|
+
marginLeft: isHovered ? 2 : 0,
|
|
124
|
+
}}
|
|
125
|
+
transition={{ duration: 0.2, ease: 'easeOut' }}
|
|
126
|
+
style={{ pointerEvents: isHovered ? 'auto' : 'none', minWidth: 0 }}
|
|
127
|
+
onPointerDown={(e) => {
|
|
128
|
+
e.preventDefault()
|
|
129
|
+
e.stopPropagation()
|
|
130
|
+
}}
|
|
131
|
+
onClick={(e) => {
|
|
132
|
+
e.preventDefault()
|
|
133
|
+
e.stopPropagation()
|
|
134
|
+
onClear()
|
|
135
|
+
}}
|
|
136
|
+
onKeyDown={(e) => {
|
|
137
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
138
|
+
e.preventDefault()
|
|
139
|
+
e.stopPropagation()
|
|
140
|
+
onClear()
|
|
141
|
+
}
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
<span className="w-4 h-4 flex items-center justify-center flex-shrink-0">
|
|
145
|
+
{defaultClose}
|
|
146
|
+
</span>
|
|
147
|
+
</motion.span>
|
|
148
|
+
) : null}
|
|
149
|
+
</>
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<button
|
|
154
|
+
ref={ref}
|
|
155
|
+
className={baseStyles}
|
|
156
|
+
disabled={disabled}
|
|
157
|
+
onMouseEnter={handleMouseEnter}
|
|
158
|
+
onMouseLeave={handleMouseLeave}
|
|
159
|
+
{...props}
|
|
160
|
+
>
|
|
161
|
+
{content}
|
|
162
|
+
</button>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
FolderButton.displayName = 'FolderButton'
|
|
168
|
+
|
|
169
|
+
export { folderButtonVariants }
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactNode, ReactElement } from 'react'
|
|
2
|
+
import { memo, Children, cloneElement, isValidElement } from 'react'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
|
|
5
|
+
type ImageChildProps = {
|
|
6
|
+
showOverlay?: boolean
|
|
7
|
+
overlayText?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface GeneratedImagesGridProps {
|
|
11
|
+
children: ReactNode
|
|
12
|
+
className?: string
|
|
13
|
+
gridClassName?: string
|
|
14
|
+
isGenerating?: boolean
|
|
15
|
+
maxVisibleWhenGenerating?: number
|
|
16
|
+
formatRemaining?: (remaining: number) => string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const GeneratedImagesGrid = memo(function GeneratedImagesGrid({
|
|
20
|
+
children,
|
|
21
|
+
className = '',
|
|
22
|
+
gridClassName = '',
|
|
23
|
+
isGenerating = false,
|
|
24
|
+
maxVisibleWhenGenerating = 6,
|
|
25
|
+
formatRemaining = (remaining: number) => `+${remaining}`,
|
|
26
|
+
}: GeneratedImagesGridProps) {
|
|
27
|
+
const childArray = Children.toArray(children)
|
|
28
|
+
const totalCount = childArray.length
|
|
29
|
+
|
|
30
|
+
const shouldTruncate = isGenerating && totalCount > maxVisibleWhenGenerating
|
|
31
|
+
const visibleCount = shouldTruncate ? maxVisibleWhenGenerating : totalCount
|
|
32
|
+
const remainingCount = totalCount - maxVisibleWhenGenerating + 1
|
|
33
|
+
|
|
34
|
+
const visibleChildren = childArray.slice(0, visibleCount)
|
|
35
|
+
|
|
36
|
+
const processedChildren = visibleChildren.map((child, index) => {
|
|
37
|
+
const isLastAndNeedOverlay = shouldTruncate && index === visibleCount - 1
|
|
38
|
+
if (isLastAndNeedOverlay && isValidElement(child)) {
|
|
39
|
+
return cloneElement(child as ReactElement<ImageChildProps>, {
|
|
40
|
+
showOverlay: true,
|
|
41
|
+
overlayText: formatRemaining(remainingCount),
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
return child
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className={cn('mt-5 mb-2', className)}>
|
|
49
|
+
<div className={cn('flex flex-wrap gap-3', gridClassName)}>
|
|
50
|
+
{processedChildren}
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
GeneratedImagesGrid.displayName = 'GeneratedImagesGrid'
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
|
|
4
|
+
export interface GenerationStatusBarProps {
|
|
5
|
+
children?: ReactNode
|
|
6
|
+
status?: string
|
|
7
|
+
icon?: ReactNode
|
|
8
|
+
time?: string
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function StatusWithAnimatedDots({ content }: { content: string }) {
|
|
13
|
+
const baseText = content.replace(/\.+$/, '')
|
|
14
|
+
return (
|
|
15
|
+
<span className="text-sm leading-sm font-medium text-primary-active">
|
|
16
|
+
{baseText}
|
|
17
|
+
<span className="status-dots-anim" style={{ fontFamily: 'inherit' }}>
|
|
18
|
+
...
|
|
19
|
+
</span>
|
|
20
|
+
</span>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const GenerationStatusBar = ({
|
|
25
|
+
children,
|
|
26
|
+
status,
|
|
27
|
+
icon,
|
|
28
|
+
time,
|
|
29
|
+
className = '',
|
|
30
|
+
}: GenerationStatusBarProps) => {
|
|
31
|
+
const content = status ?? (typeof children === 'string' ? children : null)
|
|
32
|
+
const hasContent = content != null || children != null
|
|
33
|
+
|
|
34
|
+
if (!hasContent && !icon && !time) return null
|
|
35
|
+
|
|
36
|
+
const containerStyles = cn(
|
|
37
|
+
'flex w-full h-9 items-center gap-2 rounded-t-xl bg-primary-bg px-3 text-sm leading-sm',
|
|
38
|
+
className
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className={containerStyles}>
|
|
43
|
+
<style>{`
|
|
44
|
+
@keyframes status-dots {
|
|
45
|
+
0%, 100% { opacity: 0.4; }
|
|
46
|
+
50% { opacity: 1; }
|
|
47
|
+
}
|
|
48
|
+
.status-dots-anim {
|
|
49
|
+
animation: status-dots 1.2s ease-in-out infinite;
|
|
50
|
+
}
|
|
51
|
+
`}</style>
|
|
52
|
+
{icon && (
|
|
53
|
+
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-primary-active">
|
|
54
|
+
{icon}
|
|
55
|
+
</span>
|
|
56
|
+
)}
|
|
57
|
+
{content != null && <StatusWithAnimatedDots content={String(content)} />}
|
|
58
|
+
{content == null && children != null && (
|
|
59
|
+
<span className="font-medium text-primary-active">
|
|
60
|
+
{children}
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
{time && (
|
|
64
|
+
<span className="ml-auto font-medium font-mono text-primary-active">
|
|
65
|
+
{time}
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
GenerationStatusBar.displayName = 'GenerationStatusBar'
|