@windrun-huaiin/third-ui 5.10.3 → 5.11.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windrun-huaiin/third-ui",
3
- "version": "5.10.3",
3
+ "version": "5.11.0",
4
4
  "description": "Third-party integrated UI components for windrun-huaiin projects",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -49,6 +49,7 @@
49
49
  "fumadocs-mdx": "11.6.3",
50
50
  "fumadocs-typescript": "4.0.4",
51
51
  "fumadocs-ui": "15.3.3",
52
+ "class-variance-authority": "^0.7.1",
52
53
  "katex": "^0.16.22",
53
54
  "mermaid": "^11.6.0",
54
55
  "react-medium-image-zoom": "^5.2.14",
@@ -0,0 +1,175 @@
1
+ 'use client';
2
+
3
+ import { cva } from 'class-variance-authority';
4
+ import { type HTMLAttributes, useEffect, useState } from 'react';
5
+ import { globalLucideIcons as icons } from '@base-ui/components/global-icon';
6
+ import { cn } from '@lib/utils';
7
+
8
+ const buttonVariants = cva(
9
+ 'inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50',
10
+ {
11
+ variants: {
12
+ color: {
13
+ primary:
14
+ 'bg-primary text-primary-foreground hover:bg-primary/80',
15
+ outline: 'border hover:bg-accent hover:text-accent-foreground',
16
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
17
+ secondary:
18
+ 'border bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground',
19
+ },
20
+ size: {
21
+ sm: 'gap-1 px-2 py-1.5 text-xs',
22
+ icon: 'p-1.5 [&_svg]:size-5',
23
+ 'icon-sm': 'p-1.5 [&_svg]:size-4.5',
24
+ },
25
+ },
26
+ },
27
+ );
28
+
29
+ export function Banner({
30
+ id,
31
+ variant = 'rainbow',
32
+ changeLayout = true,
33
+ height = 3,
34
+ ...props
35
+ }: HTMLAttributes<HTMLDivElement> & {
36
+ /**
37
+ * @defaultValue default is 3 rem
38
+ */
39
+ height?: number;
40
+
41
+ /**
42
+ * @defaultValue 'normal'
43
+ */
44
+ variant?: 'rainbow' | 'normal';
45
+
46
+ /**
47
+ * Change Fumadocs layout styles
48
+ *
49
+ * @defaultValue true
50
+ */
51
+ changeLayout?: boolean;
52
+ }) {
53
+ const [open, setOpen] = useState(true);
54
+ const globalKey = id ? `nd-banner-${id}` : null;
55
+ const bannerHeight = `${height}rem`;
56
+ const headerStartHeight = `${height + 5.5}rem`;
57
+
58
+ useEffect(() => {
59
+ if (globalKey) setOpen(localStorage.getItem(globalKey) !== 'true');
60
+ }, [globalKey]);
61
+
62
+ if (!open) return null;
63
+
64
+ return (
65
+ <div
66
+ id={id}
67
+ {...props}
68
+ className={cn(
69
+ 'flex flex-row items-center justify-center px-4 text-center text-sm font-medium',
70
+ 'bg-neutral-100 dark:bg-neutral-900',
71
+ !open && 'hidden',
72
+ props.className,
73
+ )}
74
+ style={{
75
+ // 将 fuma.css 中的 .sticky.top-0.z-40 样式完全移到这里
76
+ position: 'fixed',
77
+ top: 0,
78
+ left: 0,
79
+ width: '100vw',
80
+ zIndex: 1001,
81
+ height: bannerHeight,
82
+ minHeight: bannerHeight,
83
+ maxHeight: bannerHeight,
84
+ margin: 0,
85
+ borderRadius: 0,
86
+ }}
87
+ >
88
+
89
+ {globalKey ? (
90
+ <style>{`.${globalKey} #${id} { display: none; }`}</style>
91
+ ) : null}
92
+ {globalKey ? (
93
+ <script
94
+ dangerouslySetInnerHTML={{
95
+ __html: `if (localStorage.getItem('${globalKey}') === 'true') document.documentElement.classList.add('${globalKey}');`,
96
+ }}
97
+ />
98
+ ) : null}
99
+
100
+ {variant === 'rainbow' ? rainbowLayer : null}
101
+ {props.children}
102
+ {id ? (
103
+ <button
104
+ type="button"
105
+ aria-label="Close Banner"
106
+ onClick={() => {
107
+ setOpen(false);
108
+ if (globalKey) localStorage.setItem(globalKey, 'true');
109
+ }}
110
+ className={cn(
111
+ buttonVariants({
112
+ color: 'ghost',
113
+ className:
114
+ 'absolute end-2 top-1/2 -translate-y-1/2 text-neutral-600 dark:text-neutral-400',
115
+ size: 'icon',
116
+ }),
117
+ )}
118
+ >
119
+ <icons.X />
120
+ </button>
121
+ ) : null}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ const maskImage =
127
+ 'linear-gradient(to bottom,white,transparent), radial-gradient(circle at top center, white, transparent)';
128
+
129
+ const rainbowLayer = (
130
+ <>
131
+ <div
132
+ className="absolute inset-0 z-[-1]"
133
+ style={
134
+ {
135
+ maskImage,
136
+ maskComposite: 'intersect',
137
+ animation: 'fd-moving-banner 16s linear infinite',
138
+ '--start': 'rgba(0,87,255,0.5)',
139
+ '--mid': 'rgba(255,0,166,0.77)',
140
+ '--end': 'rgba(255,77,0,0.4)',
141
+ '--via': 'rgba(164,255,68,0.4)',
142
+ animationDirection: 'reverse',
143
+ backgroundImage:
144
+ 'repeating-linear-gradient(60deg, var(--end), var(--start) 2%, var(--start) 5%, transparent 8%, transparent 14%, var(--via) 18%, var(--via) 22%, var(--mid) 28%, var(--mid) 30%, var(--via) 34%, var(--via) 36%, transparent, var(--end) calc(50% - 12px))',
145
+ backgroundSize: '200% 100%',
146
+ mixBlendMode: 'difference',
147
+ } as object
148
+ }
149
+ />
150
+ <div
151
+ className="absolute inset-0 z-[-1]"
152
+ style={
153
+ {
154
+ maskImage,
155
+ maskComposite: 'intersect',
156
+ animation: 'fd-moving-banner 20s linear infinite',
157
+ '--start': 'rgba(255,120,120,0.5)',
158
+ '--mid': 'rgba(36,188,255,0.4)',
159
+ '--end': 'rgba(64,0,255,0.51)',
160
+ '--via': 'rgba(255,89,0,0.56)',
161
+ backgroundImage:
162
+ 'repeating-linear-gradient(45deg, var(--end), var(--start) 4%, var(--start) 8%, transparent 9%, transparent 14%, var(--mid) 16%, var(--mid) 20%, transparent, var(--via) 36%, var(--via) 40%, transparent 42%, var(--end) 46%, var(--end) calc(50% - 16.8px))',
163
+ backgroundSize: '200% 100%',
164
+ mixBlendMode: 'color-dodge',
165
+ } as object
166
+ }
167
+ />
168
+ <style>
169
+ {`@keyframes fd-moving-banner {
170
+ from { background-position: 0% 0; }
171
+ to { background-position: 100% 0; }
172
+ }`}
173
+ </style>
174
+ </>
175
+ );
@@ -1,16 +1,31 @@
1
1
  'use client'
2
2
 
3
- import { Banner } from 'fumadocs-ui/components/banner';
3
+ import { Banner } from './banner';
4
4
  import { useTranslations } from 'next-intl';
5
+ import { cn } from '@lib/utils';
5
6
 
6
- export function FumaBannerSuit({ showText }: { showText: boolean }) {
7
+ export function FumaBannerSuit({ showBanner }: { showBanner: boolean }) {
7
8
  const t = useTranslations('home');
9
+ const heightValue = showBanner ? 3 : 0.5;
10
+ const height= `${heightValue}rem`;
8
11
  return (
9
- showText ?
10
- (<Banner variant="rainbow" changeLayout={true}>
11
- <p className="text-xl">{t('banner')}</p>
12
- </Banner>)
13
- : (<Banner variant="normal" changeLayout={true} className="bg-white dark:bg-[rgb(10,10,10)]"/>)
12
+ <>
13
+ {/* 设置 header 的 top 位置为 Banner 的底部,避免间隙 */}
14
+ {showBanner ? (
15
+ <Banner variant="rainbow" changeLayout={true} height={heightValue}>
16
+ <p className="text-xl">{t('banner')}</p>
17
+ </Banner>
18
+ ) : (
19
+ <div
20
+ className="fixed top-0 left-0 w-screen z-[1001] m-0 rounded-none bg-neutral-100 dark:bg-neutral-900"
21
+ style={{
22
+ height: height,
23
+ minHeight: height,
24
+ maxHeight: height,
25
+ }}
26
+ />
27
+ )}
28
+ </>
14
29
  );
15
30
  }
16
31
 
@@ -0,0 +1,259 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ interface AIPromptTextareaProps {
6
+ /**
7
+ * Textarea value reference
8
+ */
9
+ value: string
10
+ /**
11
+ * Textarea value change handler
12
+ */
13
+ onChange: (value: string) => void
14
+ /**
15
+ * Word limit value reference
16
+ */
17
+ isWordLimit: boolean
18
+ /**
19
+ * Word limit value change handler
20
+ */
21
+ onWordLimitChange: (isLimit: boolean) => void
22
+ /**
23
+ * Placeholder
24
+ */
25
+ placeholder?: string
26
+ /**
27
+ * Disabled switch condition, default is false
28
+ */
29
+ disabled?: boolean
30
+ /**
31
+ * Maximum words
32
+ */
33
+ maxWords?: number
34
+ /**
35
+ * Word count unit title
36
+ */
37
+ wordUnitTitle?: string
38
+ /**
39
+ * Minimum height, px
40
+ */
41
+ minHeight?: number
42
+ /**
43
+ * Maximum height, px
44
+ */
45
+ maxHeight?: number
46
+ /**
47
+ * Word count switch, default is true
48
+ */
49
+ showWordCount?: boolean
50
+ /**
51
+ * Auto scroll switch, default is true
52
+ */
53
+ autoScroll?: boolean
54
+ /**
55
+ * Extra scroll space, px
56
+ */
57
+ extraScrollSpace?: number
58
+ /**
59
+ * Custome tailwindcss style
60
+ */
61
+ className?: string
62
+ /**
63
+ * Title text, if not provided, no title will be rendered
64
+ */
65
+ title?: string
66
+ /**
67
+ * Description text
68
+ */
69
+ description?: string
70
+ /**
71
+ * Embed title inside textarea, default is false
72
+ */
73
+ embed?: boolean
74
+ }
75
+
76
+ export function AIPromptTextarea({
77
+ value,
78
+ onChange,
79
+ placeholder = "Enter your prompt...",
80
+ disabled = false,
81
+ maxWords = 400,
82
+ wordUnitTitle = "words",
83
+ minHeight = 150,
84
+ maxHeight = 300,
85
+ className = "",
86
+ showWordCount = true,
87
+ autoScroll = true,
88
+ extraScrollSpace = 100,
89
+ isWordLimit,
90
+ onWordLimitChange,
91
+ title,
92
+ description,
93
+ embed = false
94
+ }: AIPromptTextareaProps) {
95
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
96
+
97
+ // count words
98
+ const wordArray = value.trim().split(/\s+/).filter(Boolean)
99
+ const wordCount = wordArray.length
100
+
101
+ // auto adjust textarea height
102
+ const adjustTextareaHeight = () => {
103
+ if (textareaRef.current) {
104
+ const textarea = textareaRef.current
105
+ const oldHeight = textarea.style.height
106
+
107
+ // reset height
108
+ textarea.style.height = 'auto'
109
+
110
+ // calculate content height
111
+ const contentHeight = textarea.scrollHeight
112
+
113
+ // auto adjust height between min and max height
114
+ let newHeight = Math.max(contentHeight, minHeight)
115
+ newHeight = Math.min(newHeight, maxHeight)
116
+
117
+ textarea.style.height = `${newHeight}px`
118
+
119
+ // if content height is greater than max height, show scrollbar
120
+ if (contentHeight > maxHeight) {
121
+ textarea.style.overflowY = 'auto'
122
+ } else {
123
+ textarea.style.overflowY = 'hidden'
124
+ }
125
+
126
+ // if height increased and auto scroll is enabled, scroll to appropriate position
127
+ if (autoScroll && (newHeight > parseInt(oldHeight) || !oldHeight)) {
128
+ setTimeout(() => {
129
+ const rect = textarea.getBoundingClientRect()
130
+ window.scrollTo({
131
+ top: window.pageYOffset + rect.bottom + extraScrollSpace - window.innerHeight,
132
+ behavior: 'smooth'
133
+ })
134
+ }, 0)
135
+ }
136
+ }
137
+ }
138
+
139
+ // when value changes, adjust height
140
+ useEffect(() => {
141
+ const timer = setTimeout(() => {
142
+ adjustTextareaHeight()
143
+ }, 0)
144
+ return () => clearTimeout(timer)
145
+ }, [value, minHeight, maxHeight, autoScroll, extraScrollSpace])
146
+
147
+ // handle input, limit max words
148
+ const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
149
+ const inputValue = e.target.value
150
+ const words = inputValue.trim().split(/\s+/).filter(Boolean)
151
+
152
+ // if already reached max words, and this input will exceed limit, do not update
153
+ if (wordCount >= maxWords && words.length > maxWords) {
154
+ onWordLimitChange(true)
155
+ return
156
+ }
157
+
158
+ if (words.length > maxWords) {
159
+ onChange(words.slice(0, maxWords).join(' '))
160
+ onWordLimitChange(true)
161
+ } else {
162
+ onChange(inputValue)
163
+ onWordLimitChange(false)
164
+ }
165
+ }
166
+
167
+ // when paste, also check word count
168
+ const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
169
+ const paste = e.clipboardData.getData('text')
170
+ const currentWords = value.trim().split(/\s+/).filter(Boolean)
171
+ const pasteWords = paste.trim().split(/\s+/).filter(Boolean)
172
+
173
+ if (currentWords.length >= maxWords) {
174
+ e.preventDefault()
175
+ onWordLimitChange(true)
176
+ return
177
+ }
178
+
179
+ // only allow paste remaining words
180
+ const allowed = maxWords - currentWords.length
181
+ if (pasteWords.length > allowed) {
182
+ e.preventDefault()
183
+ const newWords = currentWords.concat(pasteWords.slice(0, allowed))
184
+ onChange(newWords.join(' '))
185
+ onWordLimitChange(true)
186
+ }
187
+ }
188
+
189
+ // 渲染标题组件
190
+ const renderTitle = () => {
191
+ if (title?.trim()) return null
192
+
193
+ return (
194
+ <div className="space-y-1">
195
+ {title && <span className="text-xl font-semibold text-foreground">{title}</span>}
196
+ {description?.trim() && <span className="text-sm text-gray-400 ml-1">{description}</span>}
197
+ </div>
198
+ )
199
+ }
200
+
201
+ // 渲染textarea组件
202
+ const renderTextarea = (isEmbedded = false) => (
203
+ <textarea
204
+ ref={textareaRef}
205
+ value={value}
206
+ onChange={handleInputChange}
207
+ onPaste={handlePaste}
208
+ placeholder={placeholder}
209
+ disabled={disabled}
210
+ className={`w-full p-4 bg-transparent ${isEmbedded ? 'border-0' : 'border-2 border-border rounded-lg'} focus:outline-none focus:border-purple-400 hover:border-purple-500 transition-colors text-foreground placeholder-muted-foreground placeholder:text-base disabled:bg-muted disabled:cursor-not-allowed resize-none ${className}`}
211
+ style={{ minHeight: `${minHeight}px` }}
212
+ />
213
+ )
214
+
215
+ // 渲染单词计数
216
+ const renderWordCount = () => {
217
+ if (!showWordCount) return null
218
+
219
+ return (
220
+ <div className="flex justify-end">
221
+ <span
222
+ className={`text-sm ${
223
+ wordCount >= maxWords ? 'text-red-500' : wordCount > maxWords * 0.75 ? 'text-orange-500' : 'text-muted-foreground'
224
+ } ${isWordLimit ? 'animate-bounce' : ''}`}
225
+ onAnimationEnd={() => onWordLimitChange(false)}
226
+ >
227
+ {wordCount}/{maxWords} {wordUnitTitle}
228
+ </span>
229
+ </div>
230
+ )
231
+ }
232
+
233
+ // 如果有标题且需要嵌入,则渲染内部标题布局
234
+ if (embed && (title)) {
235
+ return (
236
+ <div className="space-y-2">
237
+ <div className="border-2 border-border rounded-lg bg-transparent">
238
+ <div className="p-4 pb-2">
239
+ {renderTitle()}
240
+ </div>
241
+ <hr className="border-t-1 border-border" />
242
+ <div className="p-1">
243
+ {renderTextarea(true)}
244
+ </div>
245
+ </div>
246
+ {renderWordCount()}
247
+ </div>
248
+ )
249
+ }
250
+
251
+ // 默认布局:外部标题或无标题
252
+ return (
253
+ <div className="space-y-2">
254
+ {renderTitle()}
255
+ {renderTextarea()}
256
+ {renderWordCount()}
257
+ </div>
258
+ )
259
+ }
@@ -1,47 +1,14 @@
1
+
1
2
  /* Has Banner */
2
- .has-banner .sticky.top-0.z-40 {
3
- position: fixed !important;
4
- top: 0 !important;
5
- left: 0 !important;
6
- width: 100vw !important;
7
- z-index: 1001 !important;
8
- height: 3rem !important;
9
- min-height: 3rem !important;
10
- max-height: 3rem !important;
11
- margin: 0 !important;
12
- border-radius: 0 !important;
13
- }
14
3
  .has-banner header#nd-nav {
15
4
  top: 2.5rem !important;
16
5
  }
17
6
 
18
7
  /* No Banner */
19
- .no-banner .sticky.top-0.z-40 {
20
- position: fixed !important;
21
- top: 0 !important;
22
- left: 0 !important;
23
- width: 100vw !important;
24
- z-index: 1001 !important;
25
- height: 0.5rem !important;
26
- min-height: 0.5rem !important;
27
- max-height: 0.5rem !important;
28
- margin: 0 !important;
29
- border-radius: 0 !important;
30
- }
31
8
  .no-banner header#nd-nav {
32
9
  top: 0rem !important;
33
10
  }
34
11
 
35
- /* Banner */
36
- .has-banner main,
37
- .has-banner .main-content {
38
- padding-top: 3rem;
39
- }
40
- .no-banner main,
41
- .no-banner .main-content {
42
- padding-top: 0.5rem;
43
- }
44
-
45
12
  /* Custome Fuma Steps */
46
13
  .fd-step::before {
47
14
  @apply size-5 -start-2.5 rounded-full;
@@ -136,4 +103,4 @@
136
103
  div[role="dialog"].rounded-lg.border.bg-fd-popover {
137
104
  min-width: 150px !important;
138
105
  /* max-width: 150px !important; */
139
- }
106
+ }