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.
Files changed (118) hide show
  1. package/README.md +188 -4
  2. package/dist/commands/add.js +93 -0
  3. package/dist/commands/diff.js +54 -0
  4. package/dist/commands/init.js +96 -0
  5. package/dist/commands/list.js +25 -0
  6. package/dist/index.js +37 -0
  7. package/dist/utils/config.js +53 -0
  8. package/dist/utils/registry.js +34 -0
  9. package/dist/utils/tokens.js +176 -0
  10. package/dist/utils/transform.js +19 -0
  11. package/package.json +33 -10
  12. package/registry/__tests__/basic/button.test.tsx +333 -0
  13. package/registry/__tests__/chat/markdown.test.tsx +387 -0
  14. package/registry/__tests__/chat/thinking-indicator.test.tsx +244 -0
  15. package/registry/__tests__/chat/tool-invocation-card.test.tsx +346 -0
  16. package/registry/basic/alert-dialog.tsx +180 -0
  17. package/registry/basic/avatar.tsx +120 -0
  18. package/registry/basic/button.tsx +100 -0
  19. package/registry/basic/collapse.tsx +94 -0
  20. package/registry/basic/collapsible-card.tsx +230 -0
  21. package/registry/basic/collapsible.tsx +21 -0
  22. package/registry/basic/dropdown-menu.tsx +254 -0
  23. package/registry/basic/icon-button.tsx +66 -0
  24. package/registry/basic/icons-inline.tsx +206 -0
  25. package/registry/basic/kbd.tsx +50 -0
  26. package/registry/basic/option-list.tsx +125 -0
  27. package/registry/basic/pagination.tsx +132 -0
  28. package/registry/basic/progress.tsx +42 -0
  29. package/registry/basic/radio-group.tsx +69 -0
  30. package/registry/basic/resizable.tsx +67 -0
  31. package/registry/basic/scrollbar.tsx +114 -0
  32. package/registry/basic/select.tsx +177 -0
  33. package/registry/basic/shimmering-text.tsx +115 -0
  34. package/registry/basic/sidebar-menu.tsx +177 -0
  35. package/registry/basic/skeleton.tsx +33 -0
  36. package/registry/basic/slider.tsx +55 -0
  37. package/registry/basic/sonner.tsx +104 -0
  38. package/registry/basic/spinner.tsx +17 -0
  39. package/registry/basic/switch.tsx +49 -0
  40. package/registry/basic/table.tsx +117 -0
  41. package/registry/basic/tabs.tsx +85 -0
  42. package/registry/basic/tag.tsx +161 -0
  43. package/registry/basic/theme-from-document.ts +10 -0
  44. package/registry/basic/toggle.tsx +223 -0
  45. package/registry/basic/tooltip.tsx +80 -0
  46. package/registry/basic/typography.tsx +201 -0
  47. package/registry/chat/ask-user-part.tsx +70 -0
  48. package/registry/chat/browser-action-part.tsx +166 -0
  49. package/registry/chat/chat-input/chat-input-folder-selector.tsx +185 -0
  50. package/registry/chat/chat-input/chat-input-model-switcher.tsx +131 -0
  51. package/registry/chat/chat-input/chat-input-textarea.tsx +67 -0
  52. package/registry/chat/chat-input/compound.tsx +334 -0
  53. package/registry/chat/chat-input/context.tsx +189 -0
  54. package/registry/chat/chat-input/folder-permission-dialog.tsx +61 -0
  55. package/registry/chat/chat-input/index.tsx +123 -0
  56. package/registry/chat/chat-input/types.ts +77 -0
  57. package/registry/chat/chat-input/useAutoResizeTextarea.ts +20 -0
  58. package/registry/chat/code-block-part.tsx +151 -0
  59. package/registry/chat/file-attachment.tsx +44 -0
  60. package/registry/chat/file-card.tsx +68 -0
  61. package/registry/chat/file-review-part.tsx +259 -0
  62. package/registry/chat/folder-button.tsx +169 -0
  63. package/registry/chat/generated-images-grid.tsx +56 -0
  64. package/registry/chat/generation-status-bar.tsx +72 -0
  65. package/registry/chat/hint-banner.tsx +165 -0
  66. package/registry/chat/image-attachment.tsx +166 -0
  67. package/registry/chat/image-generating.tsx +281 -0
  68. package/registry/chat/markdown.tsx +146 -0
  69. package/registry/chat/mermaid-part.tsx +90 -0
  70. package/registry/chat/permission-card.tsx +178 -0
  71. package/registry/chat/plan-part.tsx +168 -0
  72. package/registry/chat/queue-indicator.tsx +234 -0
  73. package/registry/chat/reasoning-step/compound.tsx +336 -0
  74. package/registry/chat/reasoning-step/context.tsx +114 -0
  75. package/registry/chat/reasoning-step/index.tsx +45 -0
  76. package/registry/chat/reasoning-step/types.ts +109 -0
  77. package/registry/chat/related-prompts.tsx +91 -0
  78. package/registry/chat/response/compound.tsx +210 -0
  79. package/registry/chat/response/context.tsx +200 -0
  80. package/registry/chat/response/index.tsx +87 -0
  81. package/registry/chat/response/types.ts +123 -0
  82. package/registry/chat/send-button.tsx +94 -0
  83. package/registry/chat/streaming-markdown-block.tsx +111 -0
  84. package/registry/chat/task-part.tsx +109 -0
  85. package/registry/chat/terminal-code-block-part.tsx +69 -0
  86. package/registry/chat/thinking-indicator.tsx +91 -0
  87. package/registry/chat/tool-invocation-card.tsx +132 -0
  88. package/registry/chat/user-message.tsx +38 -0
  89. package/registry/chat/user-question/UserQuestionCard.tsx +198 -0
  90. package/registry/chat/user-question/UserQuestionFooter.tsx +66 -0
  91. package/registry/chat/user-question/UserQuestionHeader.tsx +64 -0
  92. package/registry/chat/user-question/compound.tsx +324 -0
  93. package/registry/chat/user-question/context.tsx +456 -0
  94. package/registry/chat/user-question/index.tsx +95 -0
  95. package/registry/chat/user-question/types.ts +61 -0
  96. package/registry/chat/user-question/useUserQuestionKeyboard.ts +126 -0
  97. package/registry/chat/user-question/useUserQuestionState.ts +165 -0
  98. package/registry/chat/user-question-answer.tsx +62 -0
  99. package/registry/lib/file-icon-maps.ts +150 -0
  100. package/registry/lib/use-mermaid-render.ts +76 -0
  101. package/registry/lib/utils.ts +6 -0
  102. package/registry/meta.json +1 -0
  103. package/registry/tokens/index.css +31 -0
  104. package/registry/tokens/scale/computed.css +103 -0
  105. package/registry/tokens/scale/config.css +110 -0
  106. package/registry/tokens/scale/index.css +30 -0
  107. package/registry/tokens/scale/presets/compact.css +30 -0
  108. package/registry/tokens/scale/presets/dense.css +64 -0
  109. package/registry/tokens/scale/presets/sharp.css +40 -0
  110. package/registry/tokens/scale/presets/soft.css +16 -0
  111. package/registry/tokens/scale.css +13 -0
  112. package/registry/tokens/scrollbar-utility.css +35 -0
  113. package/registry/tokens/theme.css +633 -0
  114. package/registry/tokens/themes/dark-parchment.css +132 -0
  115. package/registry/tokens/themes/dark-qoder.css +132 -0
  116. package/registry/tokens/themes/light-parchment.css +123 -0
  117. package/registry/tokens/themes/light-qoder.css +131 -0
  118. 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'