feat: initial release v0.3.0
This commit is contained in:
387
src/components/ui/CapsuleNav.tsx
Normal file
387
src/components/ui/CapsuleNav.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
type StepStatus = 'empty' | 'active' | 'processing' | 'ready'
|
||||
|
||||
interface NavItemData {
|
||||
id: string
|
||||
icon: string
|
||||
label: string
|
||||
status: StepStatus
|
||||
href?: string // 可选的链接地址
|
||||
disabled?: boolean // 是否禁用(开发中)
|
||||
disabledLabel?: string // 禁用时显示的提示文字
|
||||
}
|
||||
|
||||
interface CapsuleNavProps {
|
||||
items: NavItemData[]
|
||||
activeId: string
|
||||
onItemClick: (id: string) => void
|
||||
projectId?: string // 用于构建链接
|
||||
episodeId?: string // 用于构建链接
|
||||
}
|
||||
|
||||
/**
|
||||
* NavItem - 胶囊导航单项
|
||||
* 支持左键点击切换、中键/Ctrl+点击在新标签页打开
|
||||
*/
|
||||
function NavItem({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
status,
|
||||
href,
|
||||
disabled,
|
||||
disabledLabel
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
label: string
|
||||
status: StepStatus
|
||||
href?: string
|
||||
disabled?: boolean
|
||||
disabledLabel?: string
|
||||
}) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (disabled) return
|
||||
if (e.button === 1 || e.ctrlKey || e.metaKey) {
|
||||
if (href) {
|
||||
window.open(href, '_blank')
|
||||
}
|
||||
return
|
||||
}
|
||||
onClick()
|
||||
}
|
||||
|
||||
const handleAuxClick = (e: React.MouseEvent) => {
|
||||
if (disabled) return
|
||||
if (e.button === 1 && href) {
|
||||
e.preventDefault()
|
||||
window.open(href, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
relative flex min-h-[52px] items-center gap-1 px-6 pt-3.5 pb-4 transition-all duration-300 ease-out
|
||||
${disabled
|
||||
? 'cursor-not-allowed'
|
||||
: active
|
||||
? 'text-[var(--glass-tone-info-fg)]'
|
||||
: 'text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-primary)]'}
|
||||
${!disabled && 'active:scale-[0.98]'}
|
||||
`}
|
||||
>
|
||||
{disabled ? (
|
||||
<span className="text-base font-medium text-[var(--glass-text-tertiary)] opacity-80">
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-base font-semibold">{label}</span>
|
||||
)}
|
||||
{/* 底部指示条 */}
|
||||
<span className={`absolute bottom-1.5 left-1/2 -translate-x-1/2 h-[3px] rounded-full transition-all duration-300 ease-out
|
||||
${active
|
||||
? 'w-6 bg-gradient-to-r from-[var(--glass-accent-from)] to-[var(--glass-accent-to)] shadow-[0_2px_8px_var(--glass-accent-shadow-soft)]'
|
||||
: 'w-0 bg-transparent'
|
||||
}`}
|
||||
/>
|
||||
{status === 'ready' && !disabled && (
|
||||
<span className={`absolute top-2 right-2 w-1.5 h-1.5 rounded-full transition-colors
|
||||
${active ? 'bg-[var(--glass-tone-info-fg)]' : 'bg-[var(--glass-tone-success-fg)]'}`}
|
||||
/>
|
||||
)}
|
||||
{status === 'processing' && !disabled && (
|
||||
<span className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full bg-[var(--glass-accent-from)] animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
{disabled && disabledLabel && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10">
|
||||
<div className="glass-surface-soft text-xs px-3 py-2 whitespace-nowrap text-[var(--glass-text-primary)]">
|
||||
{disabledLabel}
|
||||
</div>
|
||||
<div className="absolute left-1/2 -translate-x-1/2 -top-1 w-2 h-2 bg-[var(--glass-bg-surface-strong)] rotate-45 border-l border-t border-[var(--glass-stroke-base)]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* CapsuleNav - 胶囊形态悬浮导航
|
||||
* 支持中键和Ctrl+点击在新标签页打开
|
||||
*/
|
||||
export function CapsuleNav({ items, activeId, onItemClick, projectId, episodeId }: CapsuleNavProps) {
|
||||
// 构建每个导航项的链接地址
|
||||
const buildHref = (stageId: string): string | undefined => {
|
||||
if (!projectId) return undefined
|
||||
const params = new URLSearchParams()
|
||||
params.set('stage', stageId)
|
||||
if (episodeId) {
|
||||
params.set('episode', episodeId)
|
||||
}
|
||||
return `/workspace/${projectId}?${params.toString()}`
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="fixed top-20 left-1/2 -translate-x-1/2 z-50 animate-fadeInDown">
|
||||
<div
|
||||
className="flex rounded-full px-2 py-1"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.55)',
|
||||
backdropFilter: 'blur(24px) saturate(1.6)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(1.6)',
|
||||
border: '1px solid rgba(255,255,255,0.45)',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.06), 0 1.5px 6px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.7)',
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
active={activeId === item.id}
|
||||
onClick={() => onItemClick(item.id)}
|
||||
label={item.label}
|
||||
status={item.status}
|
||||
href={buildHref(item.id)}
|
||||
disabled={item.disabled}
|
||||
disabledLabel={item.disabledLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* EpisodeSelector - 剧集选择器
|
||||
*/
|
||||
interface Episode {
|
||||
id: string
|
||||
title: string
|
||||
summary?: string
|
||||
status?: {
|
||||
story?: StepStatus
|
||||
script?: StepStatus
|
||||
visual?: StepStatus
|
||||
}
|
||||
}
|
||||
|
||||
interface EpisodeSelectorProps {
|
||||
episodes: Episode[]
|
||||
currentId: string
|
||||
onSelect: (id: string) => void
|
||||
onAdd?: () => void
|
||||
onRename?: (id: string, newName: string) => void
|
||||
onDelete?: (id: string) => void
|
||||
projectName?: string // 项目名称,显示在左上角
|
||||
}
|
||||
|
||||
export function EpisodeSelector({
|
||||
episodes,
|
||||
currentId,
|
||||
onSelect,
|
||||
onAdd,
|
||||
onRename,
|
||||
onDelete,
|
||||
projectName
|
||||
}: EpisodeSelectorProps) {
|
||||
const t = useTranslations('common')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const currentEp = episodes.find(e => e.id === currentId) || episodes[0]
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
if (!currentEp) return null
|
||||
|
||||
return (
|
||||
<div className="fixed top-20 left-6 z-[60]" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-btn-base glass-btn-secondary flex items-center gap-3 px-4 py-3 transition-all group"
|
||||
style={{ borderRadius: '1.5rem' }}
|
||||
>
|
||||
<div className="glass-surface-soft flex h-10 w-10 items-center justify-center rounded-xl text-xs font-bold text-[var(--glass-tone-info-fg)]">
|
||||
{t('episode')}
|
||||
</div>
|
||||
<div className="flex flex-col items-start text-left mr-2">
|
||||
<span className="text-sm font-bold text-[var(--glass-text-primary)] line-clamp-1 max-w-[160px]">
|
||||
{projectName || t('project')}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--glass-text-secondary)] line-clamp-1 max-w-[160px]">
|
||||
{currentEp.title}
|
||||
</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="glass-surface-modal absolute left-0 top-full mt-2 w-72 origin-top-left p-2 animate-fadeIn">
|
||||
<div className="max-h-[300px] overflow-y-auto custom-scrollbar space-y-1">
|
||||
{episodes.map(ep => {
|
||||
const statusColor = ep.status?.visual === 'ready'
|
||||
? 'bg-[var(--glass-tone-success-fg)]'
|
||||
: ep.status?.script === 'ready'
|
||||
? 'bg-[var(--glass-accent-from)]'
|
||||
: 'bg-[var(--glass-stroke-strong)]'
|
||||
|
||||
// 编辑模式
|
||||
if (editingId === ep.id) {
|
||||
return (
|
||||
<div key={ep.id} className="flex items-center gap-2 p-3 rounded-xl bg-[var(--glass-tone-info-bg)] border border-[var(--glass-stroke-focus)]">
|
||||
<div className={`w-2 h-10 rounded-full ${statusColor}`} />
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && editingName.trim()) {
|
||||
onRename?.(ep.id, editingName.trim())
|
||||
setEditingId(null)
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingId(null)
|
||||
}
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-sm border border-[var(--glass-stroke-focus)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--glass-focus-ring-strong)]"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (editingName.trim()) {
|
||||
onRename?.(ep.id, editingName.trim())
|
||||
}
|
||||
setEditingId(null)
|
||||
}}
|
||||
className="w-7 h-7 rounded-lg bg-[var(--glass-accent-from)] text-white hover:bg-[var(--glass-accent-to)] flex items-center justify-center"
|
||||
>
|
||||
<AppIcon name="check" className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="w-7 h-7 rounded-lg bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-surface-strong)] flex items-center justify-center"
|
||||
>
|
||||
<AppIcon name="close" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 删除确认模式
|
||||
if (deletingId === ep.id) {
|
||||
return (
|
||||
<div key={ep.id} className="flex items-center gap-2 p-3 rounded-xl bg-[var(--glass-tone-danger-bg)] border border-[var(--glass-tone-danger-fg)]/30">
|
||||
<div className="flex-1 text-sm font-medium text-[var(--glass-tone-danger-fg)] truncate">
|
||||
{t('deleteEpisode')}:{ep.title}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onDelete?.(ep.id)
|
||||
setDeletingId(null)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="px-2 py-1 rounded-lg bg-[var(--glass-tone-danger-fg)] text-white text-xs font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('deleteEpisodeConfirm')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingId(null)}
|
||||
className="w-7 h-7 rounded-lg bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-surface-strong)] flex items-center justify-center"
|
||||
>
|
||||
<AppIcon name="close" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-xl transition-all ${ep.id === currentId
|
||||
? 'bg-[var(--glass-tone-info-bg)] border border-[var(--glass-stroke-focus)]'
|
||||
: 'hover:bg-[var(--glass-bg-muted)] border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => { onSelect(ep.id); setIsOpen(false); }}
|
||||
className="flex-1 flex items-center gap-3 text-left"
|
||||
>
|
||||
<div className={`w-2 h-10 rounded-full ${statusColor}`} />
|
||||
<div className="flex-1">
|
||||
<div className="font-bold text-[var(--glass-text-primary)] text-sm truncate">{ep.title}</div>
|
||||
{ep.summary && (
|
||||
<div className="text-xs text-[var(--glass-text-tertiary)] truncate">{ep.summary}</div>
|
||||
)}
|
||||
</div>
|
||||
{ep.id === currentId && (
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]">
|
||||
<AppIcon name="checkDot" className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{onRename && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingId(ep.id)
|
||||
setEditingName(ep.title)
|
||||
}}
|
||||
className="w-7 h-7 rounded-lg hover:bg-[var(--glass-bg-surface-strong)] flex items-center justify-center text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] transition-colors"
|
||||
title={t('editEpisodeName')}
|
||||
>
|
||||
<AppIcon name="edit" className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeletingId(ep.id)
|
||||
}}
|
||||
className="w-7 h-7 rounded-lg hover:bg-[var(--glass-tone-danger-bg)] flex items-center justify-center text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-danger-fg)] transition-colors"
|
||||
title={t('deleteEpisode')}
|
||||
>
|
||||
<AppIcon name="trash" className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{onAdd && (
|
||||
<>
|
||||
<div className="h-px bg-[var(--glass-bg-muted)] my-2 mx-2" />
|
||||
<button
|
||||
onClick={() => { onAdd(); setIsOpen(false); }}
|
||||
className="w-full flex items-center justify-center gap-2 p-2 rounded-xl text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)] font-medium text-sm transition-colors"
|
||||
>
|
||||
<span className="text-lg">+</span> {t('newEpisode')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CapsuleNav
|
||||
3
src/components/ui/ConfigModals.tsx
Normal file
3
src/components/ui/ConfigModals.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ConfigConfirmModal } from './config-modals/ConfigConfirmModal'
|
||||
export { ConfigDeleteModal } from './config-modals/ConfigDeleteModal'
|
||||
export { ConfigEditModal, SettingsModal, WorldContextModal } from './config-modals/ConfigEditModal'
|
||||
77
src/components/ui/ImagePreviewModal.tsx
Normal file
77
src/components/ui/ImagePreviewModal.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { resolveOriginalImageUrl, toDisplayImageUrl } from '@/lib/media/image-url'
|
||||
import { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface ImagePreviewModalProps {
|
||||
imageUrl: string | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ImagePreviewModal({ imageUrl, onClose }: ImagePreviewModalProps) {
|
||||
const t = useTranslations('common')
|
||||
|
||||
useEffect(() => {
|
||||
// 禁用body滚动
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset'
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
if (!imageUrl) return null
|
||||
const displayImageUrl = toDisplayImageUrl(imageUrl)
|
||||
const originalImageUrl = resolveOriginalImageUrl(imageUrl) || displayImageUrl
|
||||
if (!displayImageUrl) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[var(--glass-overlay)] backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
style={{ margin: 0, padding: 0 }}
|
||||
>
|
||||
<div className="relative max-w-7xl max-h-[90vh] p-4">
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-6 right-6 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-[var(--glass-overlay)] hover:bg-[var(--glass-overlay)] text-white transition-colors"
|
||||
>
|
||||
<AppIcon name="close" className="w-6 h-6" />
|
||||
</button>
|
||||
{originalImageUrl && (
|
||||
<a
|
||||
href={originalImageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute top-6 right-20 z-10 px-3 h-10 inline-flex items-center rounded-full bg-[var(--glass-overlay)] hover:bg-[var(--glass-overlay)] text-white text-sm transition-colors"
|
||||
>
|
||||
{t('viewOriginal')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* 图片 */}
|
||||
<MediaImageWithLoading
|
||||
src={displayImageUrl}
|
||||
alt={t('preview')}
|
||||
containerClassName="max-w-full max-h-[90vh]"
|
||||
className="max-w-full max-h-[90vh] object-contain rounded-lg shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
src/components/ui/SegmentedControl.tsx
Normal file
57
src/components/ui/SegmentedControl.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────
|
||||
|
||||
export interface SegmentedControlOption<T extends string = string> {
|
||||
value: T
|
||||
label: ReactNode
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string = string> {
|
||||
options: SegmentedControlOption<T>[]
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
/** Extra className on the outer container */
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Unified iOS-style segmented control.
|
||||
*
|
||||
* Single source of truth for all tab/segment UIs across the app.
|
||||
* Uses per-button selected styling (not a sliding indicator) for
|
||||
* pixel-perfect equal padding on all four sides.
|
||||
*/
|
||||
export function SegmentedControl<T extends string = string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className = '',
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className={`rounded-lg p-[3px] bg-[#f2f2f7] dark:bg-[#1c1c1e] shadow-inner ${className}`}>
|
||||
<div
|
||||
className="grid"
|
||||
style={{ gridTemplateColumns: `repeat(${Math.max(1, options.length)}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] font-medium transition-all cursor-pointer ${value === opt.value
|
||||
? 'bg-white text-[var(--glass-text-primary)] dark:bg-[#2c2c2e] dark:text-white shadow-[0_3px_8px_rgba(0,0,0,0.12),0_3px_1px_rgba(0,0,0,0.04)] font-bold'
|
||||
: 'text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
src/components/ui/SharedComponents.tsx
Normal file
75
src/components/ui/SharedComponents.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnimatedBackground - 流光极光背景动画
|
||||
* 用于页面全局背景
|
||||
*/
|
||||
export function AnimatedBackground() {
|
||||
return (
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden bg-[var(--glass-bg-canvas)]">
|
||||
<div className="absolute top-[-50%] left-[-50%] w-[200%] h-[200%] opacity-40 animate-aurora filter blur-[100px]">
|
||||
<div className="absolute top-0 left-0 w-1/2 h-1/2 bg-[var(--glass-bg-surface)] rounded-full mix-blend-multiply animate-blob" />
|
||||
<div className="absolute top-0 right-0 w-1/2 h-1/2 bg-[var(--glass-bg-muted)] rounded-full mix-blend-multiply animate-blob animation-delay-2000" />
|
||||
<div className="absolute bottom-0 left-0 w-1/2 h-1/2 bg-[var(--glass-bg-surface-strong)] rounded-full mix-blend-multiply animate-blob animation-delay-4000" />
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-white/60 backdrop-blur-3xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GlassPanel - 毛玻璃卡片容器
|
||||
*/
|
||||
export function GlassPanel({
|
||||
children,
|
||||
className = ''
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`
|
||||
glass-surface-elevated
|
||||
${className}
|
||||
`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Button - 通用按钮组件
|
||||
*/
|
||||
export function Button({
|
||||
children,
|
||||
primary = false,
|
||||
onClick,
|
||||
disabled = false,
|
||||
icon,
|
||||
className = ''
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
primary?: boolean
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
icon?: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
glass-btn-base px-6 py-2.5
|
||||
${primary
|
||||
? 'glass-btn-primary text-white'
|
||||
: 'glass-btn-secondary'}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{icon && <span>{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
61
src/components/ui/config-modals/ConfigConfirmModal.tsx
Normal file
61
src/components/ui/config-modals/ConfigConfirmModal.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface ConfigConfirmModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
description?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
danger?: boolean
|
||||
confirmDisabled?: boolean
|
||||
}
|
||||
|
||||
export function ConfigConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmText,
|
||||
cancelText,
|
||||
danger = false,
|
||||
confirmDisabled = false,
|
||||
}: ConfigConfirmModalProps) {
|
||||
const t = useTranslations('configModal')
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center glass-overlay animate-fadeIn"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="glass-surface-modal w-full max-w-md p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-2 text-sm text-[var(--glass-text-secondary)]">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="glass-btn-base glass-btn-secondary px-3 py-1.5 text-sm">
|
||||
{cancelText || t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={confirmDisabled}
|
||||
className={`glass-btn-base px-3 py-1.5 text-sm ${danger ? 'glass-btn-tone-danger' : 'glass-btn-primary'} disabled:pointer-events-none disabled:opacity-50`}
|
||||
>
|
||||
{confirmText || t('confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
src/components/ui/config-modals/ConfigDeleteModal.tsx
Normal file
41
src/components/ui/config-modals/ConfigDeleteModal.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ConfigConfirmModal } from './ConfigConfirmModal'
|
||||
|
||||
interface ConfigDeleteModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onDelete: () => void
|
||||
title: string
|
||||
description?: string
|
||||
deleteText?: string
|
||||
cancelText?: string
|
||||
deleteDisabled?: boolean
|
||||
}
|
||||
|
||||
export function ConfigDeleteModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onDelete,
|
||||
title,
|
||||
description,
|
||||
deleteText,
|
||||
cancelText,
|
||||
deleteDisabled = false,
|
||||
}: ConfigDeleteModalProps) {
|
||||
const t = useTranslations('configModal')
|
||||
return (
|
||||
<ConfigConfirmModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onConfirm={onDelete}
|
||||
title={title}
|
||||
description={description}
|
||||
confirmText={deleteText || t('delete')}
|
||||
cancelText={cancelText || t('cancel')}
|
||||
danger
|
||||
confirmDisabled={deleteDisabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
511
src/components/ui/config-modals/ConfigEditModal.tsx
Normal file
511
src/components/ui/config-modals/ConfigEditModal.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
ART_STYLES,
|
||||
VIDEO_RATIOS,
|
||||
} from '@/lib/constants'
|
||||
import type {
|
||||
CapabilitySelections,
|
||||
CapabilityValue,
|
||||
ModelCapabilities,
|
||||
} from '@/lib/model-config-contract'
|
||||
import { filterNormalVideoModelOptions } from '@/lib/model-capabilities/video-model-options'
|
||||
import { RatioSelector, StyleSelector } from './config-modal-selectors'
|
||||
import { ModelCapabilityDropdown } from './ModelCapabilityDropdown'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface ModelOption {
|
||||
value: string
|
||||
label: string
|
||||
provider?: string
|
||||
providerName?: string
|
||||
capabilities?: ModelCapabilities
|
||||
}
|
||||
|
||||
interface UserModels {
|
||||
llm: ModelOption[]
|
||||
image: ModelOption[]
|
||||
video: ModelOption[]
|
||||
audio: ModelOption[]
|
||||
}
|
||||
|
||||
interface CapabilityFieldDefinition {
|
||||
field: string
|
||||
options: CapabilityValue[]
|
||||
label: string
|
||||
}
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
availableModels?: Partial<UserModels>
|
||||
modelsLoaded?: boolean
|
||||
artStyle?: string
|
||||
analysisModel?: string
|
||||
characterModel?: string
|
||||
locationModel?: string
|
||||
imageModel?: string
|
||||
editModel?: string
|
||||
|
||||
videoModel?: string
|
||||
audioModel?: string
|
||||
videoRatio?: string
|
||||
capabilityOverrides?: CapabilitySelections
|
||||
ttsRate?: string
|
||||
onArtStyleChange?: (value: string) => void
|
||||
onAnalysisModelChange?: (value: string) => void
|
||||
onCharacterModelChange?: (value: string) => void
|
||||
onLocationModelChange?: (value: string) => void
|
||||
onImageModelChange?: (value: string) => void
|
||||
onEditModelChange?: (value: string) => void
|
||||
|
||||
onVideoModelChange?: (value: string) => void
|
||||
onAudioModelChange?: (value: string) => void
|
||||
onVideoRatioChange?: (value: string) => void
|
||||
onCapabilityOverridesChange?: (value: CapabilitySelections) => void
|
||||
onTTSRateChange?: (value: string) => void
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isCapabilityValue(value: unknown): value is CapabilityValue {
|
||||
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
||||
}
|
||||
|
||||
function toFieldLabel(field: string): string {
|
||||
return field.replace(/([A-Z])/g, ' $1').replace(/^./, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function parseBySample(input: string, sample: CapabilityValue): CapabilityValue {
|
||||
if (typeof sample === 'number') return Number(input)
|
||||
if (typeof sample === 'boolean') return input === 'true'
|
||||
return input
|
||||
}
|
||||
|
||||
function extractCapabilityFields(
|
||||
capabilities: ModelCapabilities | undefined,
|
||||
namespace: 'llm' | 'image' | 'video' | 'audio',
|
||||
): CapabilityFieldDefinition[] {
|
||||
const rawNamespace = capabilities?.[namespace]
|
||||
if (!isRecord(rawNamespace)) return []
|
||||
|
||||
return Object.entries(rawNamespace)
|
||||
.filter(([key, value]) => key.endsWith('Options') && Array.isArray(value) && value.every(isCapabilityValue) && value.length > 0)
|
||||
.map(([key, value]) => {
|
||||
const field = key.slice(0, -'Options'.length)
|
||||
return {
|
||||
field,
|
||||
options: value as CapabilityValue[],
|
||||
label: toFieldLabel(field),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function readCapabilitySelectionForModel(
|
||||
overrides: CapabilitySelections | undefined,
|
||||
modelKey: string | undefined,
|
||||
): Record<string, CapabilityValue> {
|
||||
if (!modelKey || !overrides) return {}
|
||||
const raw = overrides[modelKey]
|
||||
if (!isRecord(raw)) return {}
|
||||
|
||||
const normalized: Record<string, CapabilityValue> = {}
|
||||
for (const [field, value] of Object.entries(raw)) {
|
||||
if (isCapabilityValue(value)) {
|
||||
normalized[field] = value
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
availableModels,
|
||||
modelsLoaded = false,
|
||||
artStyle = 'american-comic',
|
||||
analysisModel,
|
||||
characterModel,
|
||||
locationModel,
|
||||
imageModel,
|
||||
editModel,
|
||||
videoModel,
|
||||
audioModel,
|
||||
videoRatio = '9:16',
|
||||
capabilityOverrides,
|
||||
ttsRate,
|
||||
onArtStyleChange,
|
||||
onAnalysisModelChange,
|
||||
onCharacterModelChange,
|
||||
onLocationModelChange,
|
||||
onImageModelChange,
|
||||
onEditModelChange,
|
||||
onVideoModelChange,
|
||||
onAudioModelChange,
|
||||
onVideoRatioChange,
|
||||
onCapabilityOverridesChange,
|
||||
onTTSRateChange,
|
||||
}: SettingsModalProps) {
|
||||
const t = useTranslations('configModal')
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved'>('idle')
|
||||
const userModels = useMemo<UserModels>(() => ({
|
||||
llm: Array.isArray(availableModels?.llm) ? availableModels.llm : [],
|
||||
image: Array.isArray(availableModels?.image) ? availableModels.image : [],
|
||||
video: Array.isArray(availableModels?.video) ? availableModels.video : [],
|
||||
audio: Array.isArray(availableModels?.audio) ? availableModels.audio : [],
|
||||
}), [availableModels])
|
||||
const normalVideoModels = useMemo<ModelOption[]>(
|
||||
() => filterNormalVideoModelOptions(userModels.video),
|
||||
[userModels.video],
|
||||
)
|
||||
|
||||
const selectedVideoModelOption = useMemo(
|
||||
() => normalVideoModels.find((model) => model.value === videoModel) || null,
|
||||
[normalVideoModels, videoModel],
|
||||
)
|
||||
const selectedAnalysisModelOption = useMemo(
|
||||
() => userModels.llm.find((model) => model.value === analysisModel) || null,
|
||||
[userModels.llm, analysisModel],
|
||||
)
|
||||
const selectedAudioModelOption = useMemo(
|
||||
() => userModels.audio.find((model) => model.value === audioModel) || null,
|
||||
[userModels.audio, audioModel],
|
||||
)
|
||||
|
||||
const videoCapabilityFields = useMemo(
|
||||
() => extractCapabilityFields(selectedVideoModelOption?.capabilities, 'video'),
|
||||
[selectedVideoModelOption],
|
||||
)
|
||||
const analysisCapabilityFields = useMemo(
|
||||
() => extractCapabilityFields(selectedAnalysisModelOption?.capabilities, 'llm'),
|
||||
[selectedAnalysisModelOption],
|
||||
)
|
||||
const audioCapabilityFields = useMemo(
|
||||
() => extractCapabilityFields(selectedAudioModelOption?.capabilities, 'audio'),
|
||||
[selectedAudioModelOption],
|
||||
)
|
||||
const selectedCharacterModelOption = useMemo(
|
||||
() => userModels.image.find((model) => model.value === characterModel) || null,
|
||||
[userModels.image, characterModel],
|
||||
)
|
||||
const selectedLocationModelOption = useMemo(
|
||||
() => userModels.image.find((model) => model.value === locationModel) || null,
|
||||
[userModels.image, locationModel],
|
||||
)
|
||||
const selectedStoryboardModelOption = useMemo(
|
||||
() => userModels.image.find((model) => model.value === imageModel) || null,
|
||||
[userModels.image, imageModel],
|
||||
)
|
||||
const selectedEditModelOption = useMemo(
|
||||
() => userModels.image.find((model) => model.value === editModel) || null,
|
||||
[userModels.image, editModel],
|
||||
)
|
||||
const characterCapabilityFields = useMemo(
|
||||
() => extractCapabilityFields(selectedCharacterModelOption?.capabilities, 'image'),
|
||||
[selectedCharacterModelOption],
|
||||
)
|
||||
const locationCapabilityFields = useMemo(
|
||||
() => extractCapabilityFields(selectedLocationModelOption?.capabilities, 'image'),
|
||||
[selectedLocationModelOption],
|
||||
)
|
||||
const storyboardCapabilityFields = useMemo(
|
||||
() => extractCapabilityFields(selectedStoryboardModelOption?.capabilities, 'image'),
|
||||
[selectedStoryboardModelOption],
|
||||
)
|
||||
const editCapabilityFields = useMemo(
|
||||
() => extractCapabilityFields(selectedEditModelOption?.capabilities, 'image'),
|
||||
[selectedEditModelOption],
|
||||
)
|
||||
|
||||
const selectedVideoOverrides = useMemo<Record<string, CapabilityValue>>(() => {
|
||||
return readCapabilitySelectionForModel(capabilityOverrides, videoModel)
|
||||
}, [capabilityOverrides, videoModel])
|
||||
const selectedAnalysisOverrides = useMemo<Record<string, CapabilityValue>>(() => {
|
||||
return readCapabilitySelectionForModel(capabilityOverrides, analysisModel)
|
||||
}, [capabilityOverrides, analysisModel])
|
||||
const selectedAudioOverrides = useMemo<Record<string, CapabilityValue>>(() => {
|
||||
return readCapabilitySelectionForModel(capabilityOverrides, audioModel)
|
||||
}, [capabilityOverrides, audioModel])
|
||||
const selectedCharacterOverrides = useMemo<Record<string, CapabilityValue>>(() => {
|
||||
return readCapabilitySelectionForModel(capabilityOverrides, characterModel)
|
||||
}, [capabilityOverrides, characterModel])
|
||||
const selectedLocationOverrides = useMemo<Record<string, CapabilityValue>>(() => {
|
||||
return readCapabilitySelectionForModel(capabilityOverrides, locationModel)
|
||||
}, [capabilityOverrides, locationModel])
|
||||
const selectedStoryboardOverrides = useMemo<Record<string, CapabilityValue>>(() => {
|
||||
return readCapabilitySelectionForModel(capabilityOverrides, imageModel)
|
||||
}, [capabilityOverrides, imageModel])
|
||||
const selectedEditOverrides = useMemo<Record<string, CapabilityValue>>(() => {
|
||||
return readCapabilitySelectionForModel(capabilityOverrides, editModel)
|
||||
}, [capabilityOverrides, editModel])
|
||||
|
||||
const applyCapabilityOverride = (modelKey: string | undefined, field: string, value: string, sample: CapabilityValue) => {
|
||||
if (!modelKey || !onCapabilityOverridesChange) return
|
||||
|
||||
const nextOverrides: CapabilitySelections = {
|
||||
...(capabilityOverrides || {}),
|
||||
}
|
||||
const currentSelection = isRecord(nextOverrides[modelKey])
|
||||
? { ...(nextOverrides[modelKey] as Record<string, CapabilityValue>) }
|
||||
: {}
|
||||
|
||||
if (!value) {
|
||||
delete currentSelection[field]
|
||||
} else {
|
||||
currentSelection[field] = parseBySample(value, sample)
|
||||
}
|
||||
|
||||
if (Object.keys(currentSelection).length === 0) {
|
||||
delete nextOverrides[modelKey]
|
||||
} else {
|
||||
nextOverrides[modelKey] = currentSelection
|
||||
}
|
||||
|
||||
onCapabilityOverridesChange(nextOverrides)
|
||||
showSaved()
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换模型时,自动将该模型所有 capability fields 的第一个 option 写入 overrides
|
||||
* 解决 UI 视觉上显示默认选中(第一项高亮)但 DB 实际为空,导致 requireAllFields 报错的问题
|
||||
*/
|
||||
const handleModelChange = (
|
||||
modelKey: string,
|
||||
modelOptions: ModelOption[],
|
||||
namespace: 'llm' | 'image' | 'video' | 'audio',
|
||||
onModelChangeFn?: (v: string) => void,
|
||||
) => {
|
||||
onModelChangeFn?.(modelKey)
|
||||
showSaved()
|
||||
if (!onCapabilityOverridesChange) return
|
||||
// 用新选中的模型的 capabilities 计算 fields,而不是旧模型的
|
||||
const newModel = modelOptions.find((m) => m.value === modelKey)
|
||||
const capabilityFieldsForModel = extractCapabilityFields(newModel?.capabilities, namespace)
|
||||
if (capabilityFieldsForModel.length === 0) return
|
||||
const nextOverrides: CapabilitySelections = { ...(capabilityOverrides || {}) }
|
||||
const existing = isRecord(nextOverrides[modelKey])
|
||||
? { ...(nextOverrides[modelKey] as Record<string, CapabilityValue>) }
|
||||
: {}
|
||||
// 只对尚未配置的 field 设置默认值(不覆盖已有配置)
|
||||
let changed = false
|
||||
for (const def of capabilityFieldsForModel) {
|
||||
if (existing[def.field] === undefined && def.options.length > 0) {
|
||||
existing[def.field] = def.options[0]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
nextOverrides[modelKey] = existing
|
||||
onCapabilityOverridesChange(nextOverrides)
|
||||
}
|
||||
}
|
||||
|
||||
void ttsRate
|
||||
void onTTSRateChange
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const showSaved = () => {
|
||||
setSaveStatus('saved')
|
||||
setTimeout(() => setSaveStatus('idle'), 2000)
|
||||
}
|
||||
|
||||
const handleChange = (callback?: (value: string) => void) => (value: string) => {
|
||||
callback?.(value)
|
||||
showSaved()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center glass-overlay animate-fadeIn"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="glass-surface-modal p-7 w-full max-w-3xl transform transition-all scale-100 max-h-[90vh] flex flex-col">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-2xl font-bold text-[var(--glass-text-primary)]">{t('title')}</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`glass-chip text-xs transition-all duration-300 ${saveStatus === 'saved'
|
||||
? 'glass-chip-success'
|
||||
: 'glass-chip-neutral'
|
||||
}`}>
|
||||
{saveStatus === 'saved' ? (
|
||||
<>
|
||||
<AppIcon name="check" className="w-3.5 h-3.5" />
|
||||
{t('saved')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full"></span>
|
||||
{t('autoSave')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-soft rounded-full p-2 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]"
|
||||
>
|
||||
<AppIcon name="close" className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] text-[var(--glass-text-tertiary)] mb-6">{t('subtitle')}</p>
|
||||
<div className="space-y-5 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
|
||||
<div className="glass-surface-soft p-5 sm:p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('visualStyle')}</h3>
|
||||
<div className="max-w-xs">
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={(value) => handleChange(onArtStyleChange)(value)}
|
||||
options={ART_STYLES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-surface-soft p-5 sm:p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('modelParams')}</h3>
|
||||
{!modelsLoaded && (
|
||||
<div className="text-xs text-[var(--glass-text-tertiary)]">{t('loadingModels')}</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('analysisModel')}</label>
|
||||
<ModelCapabilityDropdown
|
||||
models={userModels.llm}
|
||||
value={analysisModel}
|
||||
onModelChange={(v) => handleChange(onAnalysisModelChange)(v)}
|
||||
capabilityFields={analysisCapabilityFields}
|
||||
placementMode="downward"
|
||||
capabilityOverrides={selectedAnalysisOverrides}
|
||||
onCapabilityChange={(field, rawValue, sample) => {
|
||||
applyCapabilityOverride(analysisModel, field, rawValue, sample)
|
||||
}}
|
||||
placeholder={t('pleaseSelect')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('characterModel')}</label>
|
||||
<ModelCapabilityDropdown
|
||||
models={userModels.image}
|
||||
value={characterModel}
|
||||
onModelChange={(v) => handleModelChange(v, userModels.image, 'image', onCharacterModelChange)}
|
||||
capabilityFields={characterCapabilityFields}
|
||||
placementMode="downward"
|
||||
capabilityOverrides={selectedCharacterOverrides}
|
||||
onCapabilityChange={(field, rawValue, sample) => {
|
||||
applyCapabilityOverride(characterModel, field, rawValue, sample)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('locationModel')}</label>
|
||||
<ModelCapabilityDropdown
|
||||
models={userModels.image}
|
||||
value={locationModel}
|
||||
onModelChange={(v) => handleModelChange(v, userModels.image, 'image', onLocationModelChange)}
|
||||
capabilityFields={locationCapabilityFields}
|
||||
placementMode="downward"
|
||||
capabilityOverrides={selectedLocationOverrides}
|
||||
onCapabilityChange={(field, rawValue, sample) => {
|
||||
applyCapabilityOverride(locationModel, field, rawValue, sample)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('storyboardModel')}</label>
|
||||
<ModelCapabilityDropdown
|
||||
models={userModels.image}
|
||||
value={imageModel}
|
||||
onModelChange={(v) => handleModelChange(v, userModels.image, 'image', onImageModelChange)}
|
||||
capabilityFields={storyboardCapabilityFields}
|
||||
placementMode="downward"
|
||||
capabilityOverrides={selectedStoryboardOverrides}
|
||||
onCapabilityChange={(field, rawValue, sample) => {
|
||||
applyCapabilityOverride(imageModel, field, rawValue, sample)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('editModel')}</label>
|
||||
<ModelCapabilityDropdown
|
||||
models={userModels.image}
|
||||
value={editModel}
|
||||
onModelChange={(v) => handleModelChange(v, userModels.image, 'image', onEditModelChange)}
|
||||
capabilityFields={editCapabilityFields}
|
||||
placementMode="downward"
|
||||
capabilityOverrides={selectedEditOverrides}
|
||||
onCapabilityChange={(field, rawValue, sample) => {
|
||||
applyCapabilityOverride(editModel, field, rawValue, sample)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('videoModel')}</label>
|
||||
<ModelCapabilityDropdown
|
||||
models={normalVideoModels}
|
||||
value={videoModel}
|
||||
onModelChange={(v) => handleModelChange(v, normalVideoModels, 'video', onVideoModelChange)}
|
||||
capabilityFields={videoCapabilityFields}
|
||||
placementMode="downward"
|
||||
capabilityOverrides={selectedVideoOverrides}
|
||||
onCapabilityChange={(field, rawValue, sample) => {
|
||||
applyCapabilityOverride(videoModel, field, rawValue, sample)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('audioModel')}</label>
|
||||
<ModelCapabilityDropdown
|
||||
models={userModels.audio}
|
||||
value={audioModel}
|
||||
onModelChange={(v) => handleModelChange(v, userModels.audio, 'audio', onAudioModelChange)}
|
||||
capabilityFields={audioCapabilityFields}
|
||||
placementMode="downward"
|
||||
capabilityOverrides={selectedAudioOverrides}
|
||||
onCapabilityChange={(field, rawValue, sample) => {
|
||||
applyCapabilityOverride(audioModel, field, rawValue, sample)
|
||||
}}
|
||||
placeholder={t('pleaseSelect')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-surface-soft p-5 sm:p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('aspectRatio')}</h3>
|
||||
<div className="max-w-xs">
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={(value) => { handleChange(onVideoRatioChange)(value) }}
|
||||
options={VIDEO_RATIOS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SettingsModal as ConfigEditModal }
|
||||
export { WorldContextModal } from './WorldContextModal'
|
||||
453
src/components/ui/config-modals/ModelCapabilityDropdown.tsx
Normal file
453
src/components/ui/config-modals/ModelCapabilityDropdown.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ModelCapabilityDropdown - 方案 A 经典分区式
|
||||
* 自定义下拉组件:上半区选模型,分割线,下半区配参数
|
||||
* 触发器显示模型名 + provider + 参数摘要
|
||||
*
|
||||
* 用于:
|
||||
* - 项目配置中心 (ConfigEditModal / SettingsModal)
|
||||
* - 系统级设置中心 (ApiConfigTabContainer)
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { CapabilityValue } from '@/lib/model-config-contract'
|
||||
import { AppIcon, RatioPreviewIcon } from '@/components/ui/icons'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────
|
||||
|
||||
export interface ModelCapabilityOption {
|
||||
/** Composite key e.g. "ark::doubao-seedance-1-0-pro-250528" */
|
||||
value: string
|
||||
/** Display name */
|
||||
label: string
|
||||
/** Raw provider id */
|
||||
provider?: string
|
||||
/** Friendly provider name */
|
||||
providerName?: string
|
||||
/** Whether this model is disabled in current context */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface CapabilityFieldDefinition {
|
||||
field: string
|
||||
label: string
|
||||
options: CapabilityValue[]
|
||||
disabledOptions?: CapabilityValue[]
|
||||
}
|
||||
|
||||
export interface CapabilityBooleanToggle {
|
||||
key: string
|
||||
label: string
|
||||
value: boolean
|
||||
onChange: (next: boolean) => void
|
||||
onLabel?: string
|
||||
offLabel?: string
|
||||
}
|
||||
|
||||
export interface ModelCapabilityDropdownProps {
|
||||
/** Available model options */
|
||||
models: ModelCapabilityOption[]
|
||||
/** Currently selected model key */
|
||||
value: string | undefined
|
||||
/** Callback when model selection changes */
|
||||
onModelChange: (modelKey: string) => void
|
||||
/** Capability fields for the currently selected model */
|
||||
capabilityFields: CapabilityFieldDefinition[]
|
||||
/** Current capability override values keyed by field name */
|
||||
capabilityOverrides: Record<string, CapabilityValue>
|
||||
/** Callback when a capability value changes. Pass empty string to reset. */
|
||||
onCapabilityChange: (field: string, rawValue: string, sample: CapabilityValue) => void
|
||||
/** Optional: label text to show when no model is selected */
|
||||
placeholder?: string
|
||||
/** Optional: compact mode for smaller card contexts */
|
||||
compact?: boolean
|
||||
/** Optional: extra boolean toggles rendered in param section */
|
||||
booleanToggles?: CapabilityBooleanToggle[]
|
||||
/** Optional: control dropdown placement strategy. Defaults to 'auto'. */
|
||||
placementMode?: 'auto' | 'downward'
|
||||
}
|
||||
|
||||
const DEFAULT_PANEL_MAX_HEIGHT = 520
|
||||
const VIEWPORT_EDGE_GAP = 16
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────
|
||||
|
||||
function RatioIcon({ ratio, size = 12, selected = false }: { ratio: string; size?: number; selected?: boolean }) {
|
||||
return (
|
||||
<RatioPreviewIcon
|
||||
ratio={ratio}
|
||||
size={size}
|
||||
selected={selected}
|
||||
radiusClassName="rounded-[3px]"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function isRatioLike(field: string, options: CapabilityValue[]): boolean {
|
||||
const normalizedField = field.toLowerCase().replace(/[_\-\s]/g, '')
|
||||
if (normalizedField === 'ratio' || normalizedField === 'aspectratio') return true
|
||||
return options.every((o) => typeof o === 'string' && /^\d+:\d+$/.test(o))
|
||||
}
|
||||
|
||||
function isValidRatioText(value: string): boolean {
|
||||
return /^\d+:\d+$/.test(value)
|
||||
}
|
||||
|
||||
function shouldUseSelectControl(field: string, options: CapabilityValue[]): boolean {
|
||||
if (options.length <= 3) return false
|
||||
if (field.toLowerCase().includes('duration')) return true
|
||||
if (field.toLowerCase().includes('fps')) return true
|
||||
return options.every((item) => typeof item === 'number')
|
||||
}
|
||||
|
||||
|
||||
function isOptionDisabled(def: CapabilityFieldDefinition, option: CapabilityValue): boolean {
|
||||
if (!Array.isArray(def.disabledOptions) || def.disabledOptions.length === 0) return false
|
||||
return def.disabledOptions.includes(option)
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────
|
||||
|
||||
export function ModelCapabilityDropdown({
|
||||
models,
|
||||
value,
|
||||
onModelChange,
|
||||
capabilityFields,
|
||||
capabilityOverrides,
|
||||
onCapabilityChange,
|
||||
placeholder,
|
||||
compact = false,
|
||||
booleanToggles = [],
|
||||
placementMode = 'auto',
|
||||
}: ModelCapabilityDropdownProps) {
|
||||
const t = useTranslations('configModal')
|
||||
const tv = useTranslations('video')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})
|
||||
|
||||
const updateDropdownPlacement = useCallback(() => {
|
||||
const trigger = triggerRef.current
|
||||
if (!trigger) return
|
||||
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
const spaceAbove = Math.max(0, rect.top - VIEWPORT_EDGE_GAP)
|
||||
const spaceBelow = Math.max(0, viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP)
|
||||
const preferAutoPlacement = placementMode === 'auto'
|
||||
const shouldOpenUpward = preferAutoPlacement
|
||||
? (spaceBelow < DEFAULT_PANEL_MAX_HEIGHT && spaceAbove > spaceBelow)
|
||||
: false
|
||||
const availableSpace = shouldOpenUpward ? spaceAbove : spaceBelow
|
||||
const clampedMaxHeight = Math.max(0, Math.min(DEFAULT_PANEL_MAX_HEIGHT, Math.floor(availableSpace)))
|
||||
|
||||
|
||||
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth
|
||||
const minWidth = compact ? 340 : 400
|
||||
const panelWidth = Math.max(minWidth, rect.width)
|
||||
// Ensure panel doesn't overflow the right edge of viewport
|
||||
const maxLeft = viewportWidth - panelWidth - VIEWPORT_EDGE_GAP
|
||||
const panelLeft = Math.max(VIEWPORT_EDGE_GAP, Math.min(rect.left, maxLeft))
|
||||
|
||||
setPanelStyle({
|
||||
position: 'fixed' as const,
|
||||
left: `${panelLeft}px`,
|
||||
width: `${panelWidth}px`,
|
||||
maxHeight: `${clampedMaxHeight}px`,
|
||||
...(shouldOpenUpward
|
||||
? { bottom: `${viewportHeight - rect.top + 4}px` }
|
||||
: { top: `${rect.bottom + 4}px` }
|
||||
),
|
||||
})
|
||||
}, [compact, placementMode])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
updateDropdownPlacement()
|
||||
window.addEventListener('resize', updateDropdownPlacement)
|
||||
window.addEventListener('scroll', updateDropdownPlacement, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateDropdownPlacement)
|
||||
window.removeEventListener('scroll', updateDropdownPlacement, true)
|
||||
}
|
||||
}, [isOpen, updateDropdownPlacement])
|
||||
|
||||
const handleToggleOpen = () => {
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
updateDropdownPlacement()
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const selectedModel = models.find((m) => m.value === value)
|
||||
const visibleCapabilityFields = capabilityFields.filter((field) => field.field !== 'generationMode')
|
||||
|
||||
const resolveCapabilityLabel = useCallback((field: CapabilityFieldDefinition): string => {
|
||||
try {
|
||||
return tv(`capability.${field.field}` as never)
|
||||
} catch {
|
||||
return field.label
|
||||
}
|
||||
}, [tv])
|
||||
|
||||
/** Format option value for display — converts booleans to localized On/Off */
|
||||
const formatOptionLabel = useCallback((val: CapabilityValue): string => {
|
||||
if (val === true || val === 'true') return t('boolOn')
|
||||
if (val === false || val === 'false') return t('boolOff')
|
||||
return String(val)
|
||||
}, [t])
|
||||
|
||||
// Build summary text from capability overrides + defaults
|
||||
const paramSummary = visibleCapabilityFields
|
||||
.map((def) => {
|
||||
const val = capabilityOverrides[def.field] !== undefined
|
||||
? capabilityOverrides[def.field]
|
||||
: (def.options.length > 0 ? def.options[0] : '')
|
||||
return formatOptionLabel(val)
|
||||
})
|
||||
.concat(
|
||||
booleanToggles.map((toggle) => {
|
||||
if (toggle.value) return `${toggle.label}:${toggle.onLabel || 'On'}`
|
||||
return ''
|
||||
}),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const triggerPy = compact ? 'py-1' : 'py-2.5'
|
||||
const triggerPx = compact ? 'px-1.5' : 'px-3'
|
||||
const textSize = compact ? 'text-[11px]' : 'text-sm'
|
||||
const modelOptionTextSize = compact ? 'text-[12px]' : 'text-sm'
|
||||
|
||||
return (
|
||||
<div ref={triggerRef}>
|
||||
{/* ─── Trigger (Deep Glass Glow Style) ─── */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleOpen}
|
||||
className={`glass-input-base w-full ${triggerPx} ${triggerPy} rounded-[14px] transition-all duration-200 cursor-pointer ${isOpen
|
||||
? '!border-[var(--glass-tone-info-fg)] shadow-[0_0_0_3px_var(--glass-tone-info-bg)]'
|
||||
: 'hover:border-[var(--glass-stroke-active)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{selectedModel ? (
|
||||
<>
|
||||
<span className={`${textSize} text-[var(--glass-text-primary)] font-semibold truncate`}>
|
||||
{selectedModel.label}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={`${textSize} text-[var(--glass-text-tertiary)]`}>
|
||||
{placeholder || t('pleaseSelect')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{selectedModel && (paramSummary || selectedModel.providerName || selectedModel.provider) && (
|
||||
<span className="relative group/info">
|
||||
<AppIcon name="info" className="w-4 h-4 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] transition-colors cursor-help" />
|
||||
<span className="pointer-events-none absolute right-0 bottom-full mb-2 whitespace-nowrap rounded-lg bg-[var(--glass-text-primary)] px-3 py-1.5 text-[12px] text-white opacity-0 transition-opacity group-hover/info:opacity-100 z-50 shadow-lg">
|
||||
{[selectedModel.providerName || selectedModel.provider, paramSummary].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 transition-transform duration-300 shrink-0 ${isOpen ? 'rotate-180 text-[var(--glass-text-primary)]' : 'text-[var(--glass-text-tertiary)]'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* ─── Dropdown Panel (Portal · Deep Glass Glow) ─── */}
|
||||
{isOpen && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-[20px] shadow-[0_8px_32px_rgba(0,0,0,0.1)]"
|
||||
style={panelStyle}
|
||||
>
|
||||
{/* Model list */}
|
||||
<div className="px-2 pb-2 min-h-0 flex-1 overflow-y-auto custom-scrollbar">
|
||||
{(() => {
|
||||
// Group models by provider
|
||||
const grouped = new Map<string, ModelCapabilityOption[]>()
|
||||
for (const m of models) {
|
||||
const key = m.providerName || m.provider || 'Other'
|
||||
if (!grouped.has(key)) grouped.set(key, [])
|
||||
grouped.get(key)!.push(m)
|
||||
}
|
||||
return Array.from(grouped.entries()).map(([providerLabel, groupModels]) => (
|
||||
<div key={providerLabel} className="mb-1">
|
||||
<div className="sticky top-0 z-10 px-2 pt-2 pb-1 bg-white/80 dark:bg-[#1c1c1e]/80 backdrop-blur-md">
|
||||
<span className="text-[11px] font-bold text-[var(--glass-text-tertiary)] tracking-wide">
|
||||
{providerLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{groupModels.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (m.disabled) return
|
||||
onModelChange(m.value)
|
||||
}}
|
||||
disabled={m.disabled}
|
||||
className={`w-full text-left px-4 py-2 transition-all border-l-[3px] ${value === m.value
|
||||
? 'border-[var(--glass-tone-info-fg)] bg-[var(--glass-bg-surface-strong)] font-bold'
|
||||
: m.disabled
|
||||
? 'border-transparent text-[var(--glass-text-tertiary)] opacity-60 cursor-not-allowed'
|
||||
: 'border-transparent hover:bg-[var(--glass-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className={value === m.value
|
||||
? `${modelOptionTextSize} font-bold text-[var(--glass-text-primary)]`
|
||||
: `${modelOptionTextSize} font-medium text-[var(--glass-text-secondary)]`
|
||||
}>
|
||||
{m.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Capability params: fixed at panel bottom */}
|
||||
{(visibleCapabilityFields.length > 0 || booleanToggles.length > 0) && (
|
||||
<div data-capability-params className="shrink-0 bg-[var(--glass-bg-surface)]">
|
||||
<div className="px-4 py-3">
|
||||
<div className="text-[10px] font-bold text-[#8e8e93] uppercase tracking-wider mb-2.5">
|
||||
{t('paramConfig')}
|
||||
</div>
|
||||
<div className="max-h-[156px] overflow-y-auto custom-scrollbar pr-1">
|
||||
<div className="space-y-3">
|
||||
{visibleCapabilityFields.map((def) => {
|
||||
const currentVal = capabilityOverrides[def.field] !== undefined
|
||||
? String(capabilityOverrides[def.field])
|
||||
: ''
|
||||
const isR = isRatioLike(def.field, def.options)
|
||||
const useSelect = shouldUseSelectControl(def.field, def.options)
|
||||
const fallbackOption = def.options[0]
|
||||
const selectValue = currentVal || String(fallbackOption)
|
||||
|
||||
return (
|
||||
<div key={def.field} className="flex items-center justify-between gap-3">
|
||||
<span className="text-[13px] text-[var(--glass-text-secondary)] font-semibold shrink-0">
|
||||
{resolveCapabilityLabel(def)}
|
||||
</span>
|
||||
{def.options.length === 1 ? (
|
||||
<span className="text-[11px] font-medium px-2.5 py-1 rounded-md bg-[var(--glass-bg-surface-strong)] border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] flex items-center gap-1">
|
||||
{(() => {
|
||||
const ratioValue = String(def.options[0])
|
||||
return isR && isValidRatioText(ratioValue) ? <RatioIcon ratio={ratioValue} size={10} /> : null
|
||||
})()}
|
||||
{formatOptionLabel(def.options[0])}
|
||||
<span className="text-[var(--glass-text-tertiary)] text-[10px]">({t('fixed')})</span>
|
||||
</span>
|
||||
) : useSelect ? (
|
||||
<div className="relative group">
|
||||
<select
|
||||
value={selectValue}
|
||||
onChange={(event) => onCapabilityChange(def.field, event.target.value, def.options[0])}
|
||||
className="appearance-none bg-transparent hover:bg-[#f2f2f7] dark:hover:bg-[#1c1c1e] text-[13px] font-bold text-[var(--glass-text-primary)] pl-3 pr-7 py-1 rounded-md transition-colors outline-none cursor-pointer border border-transparent"
|
||||
>
|
||||
{def.options.map((opt) => {
|
||||
const s = String(opt)
|
||||
return (
|
||||
<option key={s} value={s}>
|
||||
{formatOptionLabel(opt)}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<AppIcon name="chevronDown" className="w-3.5 h-3.5 text-[var(--glass-text-tertiary)] absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none group-hover:text-[var(--glass-text-primary)] transition-colors" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex bg-[#f2f2f7] dark:bg-[#1c1c1e] p-[3px] rounded-lg shadow-inner">
|
||||
{def.options.map((opt) => {
|
||||
const s = String(opt)
|
||||
const disabled = isOptionDisabled(def, opt)
|
||||
const on = currentVal ? s === currentVal : s === String(fallbackOption)
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => onCapabilityChange(def.field, s, def.options[0])}
|
||||
className={`px-3 py-1.5 text-[12px] font-medium rounded-md transition-all flex items-center gap-1 cursor-pointer ${on
|
||||
? 'bg-white text-black dark:bg-[#2c2c2e] dark:text-white shadow-[0_3px_8px_rgba(0,0,0,0.12),0_3px_1px_rgba(0,0,0,0.04)] font-bold'
|
||||
: disabled
|
||||
? 'text-[#8e8e93] opacity-75 hover:opacity-95'
|
||||
: 'text-[#8e8e93] hover:text-[#3a3a3c] dark:hover:text-[#ebebf5]'
|
||||
}`}
|
||||
>
|
||||
{isR && isValidRatioText(s) && <RatioIcon ratio={s} size={10} selected={on} />}
|
||||
{formatOptionLabel(opt)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{booleanToggles.map((toggle) => (
|
||||
<div key={toggle.key} className="flex items-center justify-between gap-3">
|
||||
<span className="text-[13px] text-[var(--glass-text-secondary)] font-semibold shrink-0">
|
||||
{toggle.label}
|
||||
</span>
|
||||
<div className="flex bg-[#f2f2f7] dark:bg-[#1c1c1e] p-[3px] rounded-lg shadow-inner">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle.onChange(true)}
|
||||
className={`px-3 py-1.5 text-[12px] font-medium rounded-md transition-all ${toggle.value
|
||||
? 'bg-white text-black dark:bg-[#2c2c2e] dark:text-white shadow-[0_3px_8px_rgba(0,0,0,0.12),0_3px_1px_rgba(0,0,0,0.04)] font-bold'
|
||||
: 'text-[#8e8e93] hover:text-[#3a3a3c] dark:hover:text-[#ebebf5]'
|
||||
}`}
|
||||
>
|
||||
{toggle.onLabel || 'On'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle.onChange(false)}
|
||||
className={`px-3 py-1.5 text-[12px] font-medium rounded-md transition-all ${!toggle.value
|
||||
? 'bg-white text-black dark:bg-[#2c2c2e] dark:text-white shadow-[0_3px_8px_rgba(0,0,0,0.12),0_3px_1px_rgba(0,0,0,0.04)] font-bold'
|
||||
: 'text-[#8e8e93] hover:text-[#3a3a3c] dark:hover:text-[#ebebf5]'
|
||||
}`}
|
||||
>
|
||||
{toggle.offLabel || 'Off'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
src/components/ui/config-modals/WorldContextModal.tsx
Normal file
107
src/components/ui/config-modals/WorldContextModal.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface WorldContextModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
text: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function WorldContextModal({ isOpen, onClose, text, onChange }: WorldContextModalProps) {
|
||||
const t = useTranslations('worldContextModal')
|
||||
const tc = useTranslations('common')
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved'>('idle')
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleTextChange = (value: string) => {
|
||||
onChange(value)
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
setSaveStatus('saved')
|
||||
setTimeout(() => setSaveStatus('idle'), 2000)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center glass-overlay animate-fadeIn"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="glass-surface-modal p-7 w-full max-w-3xl transform transition-all scale-100 h-[80vh] flex flex-col">
|
||||
<div className="flex justify-between items-center mb-6 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-[var(--glass-text-primary)]">{t('title')}</h2>
|
||||
<p className="text-[var(--glass-text-tertiary)] text-sm">{t('description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`glass-chip text-xs transition-all duration-300 ${
|
||||
saveStatus === 'saved' ? 'glass-chip-success' : 'glass-chip-neutral'
|
||||
}`}
|
||||
>
|
||||
{saveStatus === 'saved' ? (
|
||||
<>
|
||||
<AppIcon name="check" className="w-3.5 h-3.5" />
|
||||
{tc('saved')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full"></span>
|
||||
{tc('autoSave')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-soft rounded-full p-2 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]"
|
||||
>
|
||||
<AppIcon name="close" className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 glass-surface-soft p-4 overflow-hidden flex flex-col">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(event) => handleTextChange(event.target.value)}
|
||||
placeholder={t('placeholder')}
|
||||
className="glass-textarea-base flex-1 text-base resize-none leading-relaxed placeholder:text-[var(--glass-text-tertiary)]/70 custom-scrollbar p-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-0 flex justify-start items-center flex-shrink-0">
|
||||
<span className="text-xs text-[var(--glass-text-tertiary)]">{t('hint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
src/components/ui/config-modals/config-modal-selectors.tsx
Normal file
161
src/components/ui/config-modals/config-modal-selectors.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AppIcon, RatioPreviewIcon } from '@/components/ui/icons'
|
||||
|
||||
interface RatioIconProps {
|
||||
ratio: string
|
||||
size?: number
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
interface RatioSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
interface StyleSelectorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
function RatioIcon({ ratio, size = 24, selected = false }: RatioIconProps) {
|
||||
// 始终以选中态渲染图标,保证所有比例选项的图标统一为蓝色
|
||||
return (
|
||||
<RatioPreviewIcon
|
||||
ratio={ratio}
|
||||
size={size}
|
||||
selected={selected || true}
|
||||
variant="surface"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function RatioSelector({ value, onChange, options }: RatioSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find((option) => option.value === value)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<RatioIcon ratio={value} size={20} selected />
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">
|
||||
{selectedOption?.label || value}
|
||||
</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar"
|
||||
style={{ minWidth: '280px' }}
|
||||
>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-[var(--glass-bg-muted)] transition-colors ${
|
||||
value === option.value
|
||||
? 'bg-[var(--glass-tone-info-bg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<RatioIcon ratio={option.value} size={28} selected={value === option.value} />
|
||||
<span
|
||||
className={`text-xs ${
|
||||
value === option.value
|
||||
? 'text-[var(--glass-tone-info-fg)] font-medium'
|
||||
: 'text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StyleSelector({ value, onChange, options }: StyleSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find((option) => option.value === value) || options[0]
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption.label}</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center p-3 rounded-lg text-left transition-all ${
|
||||
value === option.value
|
||||
? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
|
||||
: 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-sm">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
src/components/ui/icons/AppIcon.tsx
Normal file
14
src/components/ui/icons/AppIcon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { iconRegistry, type AppIconName } from './registry'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
|
||||
export interface AppIconProps extends Omit<LucideProps, 'ref'> {
|
||||
name: AppIconName
|
||||
}
|
||||
|
||||
export function AppIcon({ name, ...props }: AppIconProps) {
|
||||
const IconComponent = iconRegistry[name]
|
||||
if (!IconComponent) {
|
||||
throw new Error(`Unknown AppIcon name: ${String(name)}`)
|
||||
}
|
||||
return <IconComponent {...props} />
|
||||
}
|
||||
54
src/components/ui/icons/RatioPreviewIcon.tsx
Normal file
54
src/components/ui/icons/RatioPreviewIcon.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
type RatioPreviewVariant = 'surface' | 'surfaceStrong'
|
||||
|
||||
export interface RatioPreviewIconProps {
|
||||
ratio: string
|
||||
size?: number
|
||||
selected?: boolean
|
||||
variant?: RatioPreviewVariant
|
||||
radiusClassName?: string
|
||||
}
|
||||
|
||||
function resolveUnselectedClass(variant: RatioPreviewVariant): string {
|
||||
if (variant === 'surface') {
|
||||
return 'bg-[var(--glass-bg-surface)] shadow-[0_0_0_1px_rgba(163,181,214,0.25)]'
|
||||
}
|
||||
return 'bg-[var(--glass-bg-surface-strong)] shadow-[0_0_0_1px_rgba(163,181,214,0.24)]'
|
||||
}
|
||||
|
||||
export function RatioPreviewIcon({
|
||||
ratio,
|
||||
size = 24,
|
||||
selected = false,
|
||||
variant = 'surfaceStrong',
|
||||
radiusClassName = 'rounded-[6px]',
|
||||
}: RatioPreviewIconProps) {
|
||||
const [widthRatio, heightRatio] = ratio.split(':').map(Number)
|
||||
if (!Number.isFinite(widthRatio) || !Number.isFinite(heightRatio) || widthRatio <= 0 || heightRatio <= 0) {
|
||||
throw new Error(`Invalid ratio for RatioPreviewIcon: ${ratio}`)
|
||||
}
|
||||
|
||||
const maxDimension = size
|
||||
let width = maxDimension
|
||||
let height = maxDimension
|
||||
|
||||
if (widthRatio >= heightRatio) {
|
||||
height = Math.round((maxDimension * heightRatio) / widthRatio)
|
||||
} else {
|
||||
width = Math.round((maxDimension * widthRatio) / heightRatio)
|
||||
}
|
||||
|
||||
const style: CSSProperties = {
|
||||
width,
|
||||
height,
|
||||
minWidth: width,
|
||||
minHeight: height,
|
||||
}
|
||||
|
||||
const toneClass = selected
|
||||
? 'bg-[var(--glass-tone-info-bg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
|
||||
: resolveUnselectedClass(variant)
|
||||
|
||||
return <span aria-hidden="true" className={`${radiusClassName} block transition-all ${toneClass}`} style={style} />
|
||||
}
|
||||
732
src/components/ui/icons/custom.tsx
Normal file
732
src/components/ui/icons/custom.tsx
Normal file
@@ -0,0 +1,732 @@
|
||||
import { forwardRef, type ForwardRefExoticComponent, type RefAttributes, type SVGProps } from 'react'
|
||||
import { createLucideIcon as createLucideIconBase, type IconNode, type LucideProps } from 'lucide-react'
|
||||
|
||||
type CustomIconComponent = ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>
|
||||
|
||||
function createLucideIcon(name: string, iconNode: IconNode) {
|
||||
const keyedIconNode: IconNode = iconNode.map(([tag, attrs], index) => [
|
||||
tag,
|
||||
{
|
||||
...attrs,
|
||||
key: `${name}-${index}`,
|
||||
},
|
||||
])
|
||||
return createLucideIconBase(name, keyedIconNode)
|
||||
}
|
||||
|
||||
const CustomIcon001Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M6 18L18 6M6 6l12 12", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon001Base = createLucideIcon('CustomIcon001', CustomIcon001Node)
|
||||
export const CustomIcon001: CustomIconComponent = CustomIcon001Base
|
||||
|
||||
const CustomIcon002Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon002Base = createLucideIcon('CustomIcon002', CustomIcon002Node)
|
||||
export const CustomIcon002: CustomIconComponent = CustomIcon002Base
|
||||
|
||||
const CustomIcon003Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon003Base = createLucideIcon('CustomIcon003', CustomIcon003Node)
|
||||
export const CustomIcon003: CustomIconComponent = CustomIcon003Base
|
||||
|
||||
const CustomIcon004Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M5 13l4 4L19 7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon004Base = createLucideIcon('CustomIcon004', CustomIcon004Node)
|
||||
export const CustomIcon004: CustomIconComponent = CustomIcon004Base
|
||||
|
||||
const CustomIcon005Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon005Base = createLucideIcon('CustomIcon005', CustomIcon005Node)
|
||||
export const CustomIcon005: CustomIconComponent = CustomIcon005Base
|
||||
|
||||
const CustomIcon006Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M12 4v16m8-8H4", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon006Base = createLucideIcon('CustomIcon006', CustomIcon006Node)
|
||||
export const CustomIcon006: CustomIconComponent = CustomIcon006Base
|
||||
|
||||
const CustomIcon007Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M19 9l-7 7-7-7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon007Base = createLucideIcon('CustomIcon007', CustomIcon007Node)
|
||||
export const CustomIcon007: CustomIconComponent = CustomIcon007Base
|
||||
|
||||
const CustomIcon008Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon008Base = createLucideIcon('CustomIcon008', CustomIcon008Node)
|
||||
export const CustomIcon008: CustomIconComponent = CustomIcon008Base
|
||||
|
||||
const CustomIcon009Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M13 10V3L4 14h7v7l9-11h-7z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon009Base = createLucideIcon('CustomIcon009', CustomIcon009Node)
|
||||
export const CustomIcon009: CustomIconComponent = CustomIcon009Base
|
||||
|
||||
const CustomIcon010Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon010Base = createLucideIcon('CustomIcon010', CustomIcon010Node)
|
||||
export const CustomIcon010: CustomIconComponent = CustomIcon010Base
|
||||
|
||||
const CustomIcon011Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon011Base = createLucideIcon('CustomIcon011', CustomIcon011Node)
|
||||
export const CustomIcon011: CustomIconComponent = CustomIcon011Base
|
||||
|
||||
const CustomIcon012Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2.2", d: "M6 18L18 6M6 6l12 12", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon012Base = createLucideIcon('CustomIcon012', CustomIcon012Node)
|
||||
export const CustomIcon012: CustomIconComponent = CustomIcon012Base
|
||||
|
||||
const CustomIcon013Node: IconNode = [
|
||||
['path', { d: "M8 5v14l11-7z", fill: "currentColor" }],
|
||||
]
|
||||
const CustomIcon013Base = createLucideIcon('CustomIcon013', CustomIcon013Node)
|
||||
export const CustomIcon013: CustomIconComponent = CustomIcon013Base
|
||||
|
||||
const CustomIcon014Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon014Base = createLucideIcon('CustomIcon014', CustomIcon014Node)
|
||||
export const CustomIcon014: CustomIconComponent = CustomIcon014Base
|
||||
|
||||
const CustomIcon015Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M9 5l7 7-7 7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon015Base = createLucideIcon('CustomIcon015', CustomIcon015Node)
|
||||
export const CustomIcon015: CustomIconComponent = CustomIcon015Base
|
||||
|
||||
const CustomIcon016Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon016Base = createLucideIcon('CustomIcon016', CustomIcon016Node)
|
||||
export const CustomIcon016: CustomIconComponent = CustomIcon016Base
|
||||
|
||||
const CustomIcon017Node: IconNode = [
|
||||
['rect', { x: "6", y: "5", width: "4", height: "14", rx: "1", fill: "currentColor" }],
|
||||
['rect', { x: "14", y: "5", width: "4", height: "14", rx: "1", fill: "currentColor" }],
|
||||
]
|
||||
const CustomIcon017Base = createLucideIcon('CustomIcon017', CustomIcon017Node)
|
||||
export const CustomIcon017: CustomIconComponent = CustomIcon017Base
|
||||
|
||||
const CustomIcon018Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon018Base = createLucideIcon('CustomIcon018', CustomIcon018Node)
|
||||
export const CustomIcon018: CustomIconComponent = CustomIcon018Base
|
||||
|
||||
const CustomIcon019Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon019Base = createLucideIcon('CustomIcon019', CustomIcon019Node)
|
||||
export const CustomIcon019: CustomIconComponent = CustomIcon019Base
|
||||
|
||||
const CustomIcon020Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.8", d: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon020Base = createLucideIcon('CustomIcon020', CustomIcon020Node)
|
||||
export const CustomIcon020: CustomIconComponent = CustomIcon020Base
|
||||
|
||||
const CustomIcon021Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.8", d: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon021Base = createLucideIcon('CustomIcon021', CustomIcon021Node)
|
||||
export const CustomIcon021: CustomIconComponent = CustomIcon021Base
|
||||
|
||||
const CustomIcon022Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon022Base = createLucideIcon('CustomIcon022', CustomIcon022Node)
|
||||
export const CustomIcon022: CustomIconComponent = CustomIcon022Base
|
||||
|
||||
const CustomIcon023Node: IconNode = [
|
||||
['path', { fillRule: "evenodd", d: "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", clipRule: "evenodd", fill: "currentColor" }],
|
||||
]
|
||||
const CustomIcon023Base = createLucideIcon('CustomIcon023', CustomIcon023Node)
|
||||
export const CustomIcon023: CustomIconComponent = forwardRef<SVGSVGElement, LucideProps>(function CustomIcon023(props, ref) {
|
||||
return <CustomIcon023Base ref={ref} {...props} viewBox="0 0 20 20" />
|
||||
})
|
||||
|
||||
const CustomIcon024Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon024Base = createLucideIcon('CustomIcon024', CustomIcon024Node)
|
||||
export const CustomIcon024: CustomIconComponent = CustomIcon024Base
|
||||
|
||||
const CustomIcon025Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "3", d: "M5 13l4 4L19 7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon025Base = createLucideIcon('CustomIcon025', CustomIcon025Node)
|
||||
export const CustomIcon025: CustomIconComponent = CustomIcon025Base
|
||||
|
||||
const CustomIcon026Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M12 6v6m0 0v6m0-6h6m-6 0H6", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon026Base = createLucideIcon('CustomIcon026', CustomIcon026Node)
|
||||
export const CustomIcon026: CustomIconComponent = CustomIcon026Base
|
||||
|
||||
const CustomIcon027Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon027Base = createLucideIcon('CustomIcon027', CustomIcon027Node)
|
||||
export const CustomIcon027: CustomIconComponent = CustomIcon027Base
|
||||
|
||||
const CustomIcon028Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2.2", d: "M5 13l4 4L19 7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon028Base = createLucideIcon('CustomIcon028', CustomIcon028Node)
|
||||
export const CustomIcon028: CustomIconComponent = CustomIcon028Base
|
||||
|
||||
const CustomIcon029Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon029Base = createLucideIcon('CustomIcon029', CustomIcon029Node)
|
||||
export const CustomIcon029: CustomIconComponent = CustomIcon029Base
|
||||
|
||||
const CustomIcon030Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon030Base = createLucideIcon('CustomIcon030', CustomIcon030Node)
|
||||
export const CustomIcon030: CustomIconComponent = CustomIcon030Base
|
||||
|
||||
const CustomIcon031Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2.3", d: "M5 13l4 4L19 7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon031Base = createLucideIcon('CustomIcon031', CustomIcon031Node)
|
||||
export const CustomIcon031: CustomIconComponent = CustomIcon031Base
|
||||
|
||||
const CustomIcon032Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M4 6h16M4 12h16m-7 6h7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon032Base = createLucideIcon('CustomIcon032', CustomIcon032Node)
|
||||
export const CustomIcon032: CustomIconComponent = CustomIcon032Base
|
||||
|
||||
const CustomIcon033Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z", fill: "none", stroke: "currentColor" }],
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon033Base = createLucideIcon('CustomIcon033', CustomIcon033Node)
|
||||
export const CustomIcon033: CustomIconComponent = CustomIcon033Base
|
||||
|
||||
const CustomIcon034Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon034Base = createLucideIcon('CustomIcon034', CustomIcon034Node)
|
||||
export const CustomIcon034: CustomIconComponent = CustomIcon034Base
|
||||
|
||||
const CustomIcon035Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2.4", d: "M5 13l4 4L19 7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon035Base = createLucideIcon('CustomIcon035', CustomIcon035Node)
|
||||
export const CustomIcon035: CustomIconComponent = CustomIcon035Base
|
||||
|
||||
const CustomIcon036Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon036Base = createLucideIcon('CustomIcon036', CustomIcon036Node)
|
||||
export const CustomIcon036: CustomIconComponent = CustomIcon036Base
|
||||
|
||||
const CustomIcon037Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M15 19l-7-7 7-7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon037Base = createLucideIcon('CustomIcon037', CustomIcon037Node)
|
||||
export const CustomIcon037: CustomIconComponent = CustomIcon037Base
|
||||
|
||||
const CustomIcon038Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.7", d: "M4 8l8-4 8 4-8 4-8-4z", fill: "none", stroke: "currentColor" }],
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.7", d: "M4 8v8l8 4 8-4V8", fill: "none", stroke: "currentColor" }],
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.7", d: "M12 12v8", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon038Base = createLucideIcon('CustomIcon038', CustomIcon038Node)
|
||||
export const CustomIcon038: CustomIconComponent = CustomIcon038Base
|
||||
|
||||
const CustomIcon039Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon039Base = createLucideIcon('CustomIcon039', CustomIcon039Node)
|
||||
export const CustomIcon039: CustomIconComponent = CustomIcon039Base
|
||||
|
||||
const CustomIcon040Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon040Base = createLucideIcon('CustomIcon040', CustomIcon040Node)
|
||||
export const CustomIcon040: CustomIconComponent = CustomIcon040Base
|
||||
|
||||
const CustomIcon041Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon041Base = createLucideIcon('CustomIcon041', CustomIcon041Node)
|
||||
export const CustomIcon041: CustomIconComponent = CustomIcon041Base
|
||||
|
||||
const CustomIcon042Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2.5", d: "M5 13l4 4L19 7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon042Base = createLucideIcon('CustomIcon042', CustomIcon042Node)
|
||||
export const CustomIcon042: CustomIconComponent = CustomIcon042Base
|
||||
|
||||
const CustomIcon043Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.5", d: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon043Base = createLucideIcon('CustomIcon043', CustomIcon043Node)
|
||||
export const CustomIcon043: CustomIconComponent = CustomIcon043Base
|
||||
|
||||
const CustomIcon044Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon044Base = createLucideIcon('CustomIcon044', CustomIcon044Node)
|
||||
export const CustomIcon044: CustomIconComponent = CustomIcon044Base
|
||||
|
||||
const CustomIcon045Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M12 1v11m0 0a4 4 0 004-4V5a4 4 0 00-8 0v3a4 4 0 004 4zm-7 3a7 7 0 0014 0M9 21h6", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon045Base = createLucideIcon('CustomIcon045', CustomIcon045Node)
|
||||
export const CustomIcon045: CustomIconComponent = CustomIcon045Base
|
||||
|
||||
const CustomIcon046Node: IconNode = [
|
||||
['circle', { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4", fill: "none" }],
|
||||
['path', { fill: "currentColor", d: "M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" }],
|
||||
]
|
||||
const CustomIcon046Base = createLucideIcon('CustomIcon046', CustomIcon046Node)
|
||||
export const CustomIcon046: CustomIconComponent = CustomIcon046Base
|
||||
|
||||
const CustomIcon047Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M9 5v10l8 4V1L9 5zM5 9v6M3 10v4", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon047Base = createLucideIcon('CustomIcon047', CustomIcon047Node)
|
||||
export const CustomIcon047: CustomIconComponent = CustomIcon047Base
|
||||
|
||||
const CustomIcon048Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M9 12h6M9 16h6M8 7h8a2 2 0 012 2v10l-3-2-3 2-3-2-3 2V9a2 2 0 012-2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon048Base = createLucideIcon('CustomIcon048', CustomIcon048Node)
|
||||
export const CustomIcon048: CustomIconComponent = CustomIcon048Base
|
||||
|
||||
const CustomIcon049Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z", fill: "none", stroke: "currentColor" }],
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon049Base = createLucideIcon('CustomIcon049', CustomIcon049Node)
|
||||
export const CustomIcon049: CustomIconComponent = CustomIcon049Base
|
||||
|
||||
const CustomIcon050Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M15.536 8.464a5 5 0 010 7.072M12 6v12m0 0l-4-4m4 4l4-4M19.07 4.93a10 10 0 010 14.14M5 12a7 7 0 0114 0", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon050Base = createLucideIcon('CustomIcon050', CustomIcon050Node)
|
||||
export const CustomIcon050: CustomIconComponent = CustomIcon050Base
|
||||
|
||||
const CustomIcon051Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon051Base = createLucideIcon('CustomIcon051', CustomIcon051Node)
|
||||
export const CustomIcon051: CustomIconComponent = CustomIcon051Base
|
||||
|
||||
const CustomIcon052Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.5", d: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z", fill: "none", stroke: "currentColor" }],
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.5", d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon052Base = createLucideIcon('CustomIcon052', CustomIcon052Node)
|
||||
export const CustomIcon052: CustomIconComponent = CustomIcon052Base
|
||||
|
||||
const CustomIcon053Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.5", d: "M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon053Base = createLucideIcon('CustomIcon053', CustomIcon053Node)
|
||||
export const CustomIcon053: CustomIconComponent = CustomIcon053Base
|
||||
|
||||
const CustomIcon054Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon054Base = createLucideIcon('CustomIcon054', CustomIcon054Node)
|
||||
export const CustomIcon054: CustomIconComponent = CustomIcon054Base
|
||||
|
||||
const CustomIcon055Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon055Base = createLucideIcon('CustomIcon055', CustomIcon055Node)
|
||||
export const CustomIcon055: CustomIconComponent = CustomIcon055Base
|
||||
|
||||
const CustomIcon056Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M20 12H4", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon056Base = createLucideIcon('CustomIcon056', CustomIcon056Node)
|
||||
export const CustomIcon056: CustomIconComponent = CustomIcon056Base
|
||||
|
||||
const CustomIcon057Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.5", d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon057Base = createLucideIcon('CustomIcon057', CustomIcon057Node)
|
||||
export const CustomIcon057: CustomIconComponent = CustomIcon057Base
|
||||
|
||||
const CustomIcon058Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M3 5h18v14H3zM9 19h6", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon058Base = createLucideIcon('CustomIcon058', CustomIcon058Node)
|
||||
export const CustomIcon058: CustomIconComponent = CustomIcon058Base
|
||||
|
||||
const CustomIcon059Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M12 3a9 9 0 100 18h1a3 3 0 000-6h-1a3 3 0 010-6h1a3 3 0 100-6h-1z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon059Base = createLucideIcon('CustomIcon059', CustomIcon059Node)
|
||||
export const CustomIcon059: CustomIconComponent = CustomIcon059Base
|
||||
|
||||
const CustomIcon060Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M13 7l5 5m0 0l-5 5m5-5H6", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon060Base = createLucideIcon('CustomIcon060', CustomIcon060Node)
|
||||
export const CustomIcon060: CustomIconComponent = CustomIcon060Base
|
||||
|
||||
const CustomIcon061Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.7", d: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z", fill: "none", stroke: "currentColor" }],
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.7", d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon061Base = createLucideIcon('CustomIcon061', CustomIcon061Node)
|
||||
export const CustomIcon061: CustomIconComponent = CustomIcon061Base
|
||||
|
||||
const CustomIcon062Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M12 3l8 7-8 11L4 10l8-7z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon062Base = createLucideIcon('CustomIcon062', CustomIcon062Node)
|
||||
export const CustomIcon062: CustomIconComponent = CustomIcon062Base
|
||||
|
||||
const CustomIcon063Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.5", d: "M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon063Base = createLucideIcon('CustomIcon063', CustomIcon063Node)
|
||||
export const CustomIcon063: CustomIconComponent = CustomIcon063Base
|
||||
|
||||
const CustomIcon064Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M3 20h18M7 20l4-7 2 3 3-5 5 9H7z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon064Base = createLucideIcon('CustomIcon064', CustomIcon064Node)
|
||||
export const CustomIcon064: CustomIconComponent = CustomIcon064Base
|
||||
|
||||
const CustomIcon065Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon065Base = createLucideIcon('CustomIcon065', CustomIcon065Node)
|
||||
export const CustomIcon065: CustomIconComponent = CustomIcon065Base
|
||||
|
||||
const CustomIcon066Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon066Base = createLucideIcon('CustomIcon066', CustomIcon066Node)
|
||||
export const CustomIcon066: CustomIconComponent = CustomIcon066Base
|
||||
|
||||
const CustomIcon067Node: IconNode = [
|
||||
['path', { fillRule: "evenodd", d: "M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z", clipRule: "evenodd", fill: "currentColor" }],
|
||||
]
|
||||
const CustomIcon067Base = createLucideIcon('CustomIcon067', CustomIcon067Node)
|
||||
export const CustomIcon067: CustomIconComponent = forwardRef<SVGSVGElement, LucideProps>(function CustomIcon067(props, ref) {
|
||||
return <CustomIcon067Base ref={ref} {...props} viewBox="0 0 20 20" />
|
||||
})
|
||||
|
||||
const CustomIcon068Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.8", d: "M7 3h7l5 5v13a1 1 0 01-1 1H7a2 2 0 01-2-2V5a2 2 0 012-2z", fill: "none", stroke: "currentColor" }],
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.8", d: "M14 3v5h5M9 12h6M9 16h6", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon068Base = createLucideIcon('CustomIcon068', CustomIcon068Node)
|
||||
export const CustomIcon068: CustomIconComponent = CustomIcon068Base
|
||||
|
||||
const CustomIcon069Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.5", d: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon069Base = createLucideIcon('CustomIcon069', CustomIcon069Node)
|
||||
export const CustomIcon069: CustomIconComponent = CustomIcon069Base
|
||||
|
||||
const CustomIcon070Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z", fill: "none", stroke: "currentColor" }],
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon070Base = createLucideIcon('CustomIcon070', CustomIcon070Node)
|
||||
export const CustomIcon070: CustomIconComponent = CustomIcon070Base
|
||||
|
||||
const CustomIcon071Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M14 5l7 7m0 0l-7 7m7-7H3", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon071Base = createLucideIcon('CustomIcon071', CustomIcon071Node)
|
||||
export const CustomIcon071: CustomIconComponent = CustomIcon071Base
|
||||
|
||||
const CustomIcon072Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M9 17v-6m3 6V7m3 10v-3M5 20h14a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v14a1 1 0 001 1z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon072Base = createLucideIcon('CustomIcon072', CustomIcon072Node)
|
||||
export const CustomIcon072: CustomIconComponent = CustomIcon072Base
|
||||
|
||||
const CustomIcon073Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.5", d: "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon073Base = createLucideIcon('CustomIcon073', CustomIcon073Node)
|
||||
export const CustomIcon073: CustomIconComponent = CustomIcon073Base
|
||||
|
||||
const CustomIcon074Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2.4", d: "M6 18L18 6M6 6l12 12", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon074Base = createLucideIcon('CustomIcon074', CustomIcon074Node)
|
||||
export const CustomIcon074: CustomIconComponent = CustomIcon074Base
|
||||
|
||||
const CustomIcon075Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.8", d: "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M4 18h9a2 2 0 002-2V8a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon075Base = createLucideIcon('CustomIcon075', CustomIcon075Node)
|
||||
export const CustomIcon075: CustomIconComponent = CustomIcon075Base
|
||||
|
||||
const CustomIcon076Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2.2", d: "M9 5l7 7-7 7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon076Base = createLucideIcon('CustomIcon076', CustomIcon076Node)
|
||||
export const CustomIcon076: CustomIconComponent = CustomIcon076Base
|
||||
|
||||
const CustomIcon077Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2.2", d: "M12 4v16m8-8H4", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon077Base = createLucideIcon('CustomIcon077', CustomIcon077Node)
|
||||
export const CustomIcon077: CustomIconComponent = CustomIcon077Base
|
||||
|
||||
const CustomIcon078Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2.1", d: "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon078Base = createLucideIcon('CustomIcon078', CustomIcon078Node)
|
||||
export const CustomIcon078: CustomIconComponent = CustomIcon078Base
|
||||
|
||||
const CustomIcon079Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M5 15l7-7 7 7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon079Base = createLucideIcon('CustomIcon079', CustomIcon079Node)
|
||||
export const CustomIcon079: CustomIconComponent = CustomIcon079Base
|
||||
|
||||
const CustomIcon080Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M14.121 14.121L4.5 4.5m9.621 9.621l2.379 2.379m-2.379-2.379L21 7.242M6.75 6.75l-.75-.75M6 18l4.5-4.5m0 0L18 21", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon080Base = createLucideIcon('CustomIcon080', CustomIcon080Node)
|
||||
export const CustomIcon080: CustomIconComponent = CustomIcon080Base
|
||||
|
||||
const CustomIcon081Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z", fill: "none", stroke: "currentColor" }],
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M21 12a9 9 0 11-18 0 9 9 0 0118 0z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon081Base = createLucideIcon('CustomIcon081', CustomIcon081Node)
|
||||
export const CustomIcon081: CustomIconComponent = CustomIcon081Base
|
||||
|
||||
const CustomIcon082Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon082Base = createLucideIcon('CustomIcon082', CustomIcon082Node)
|
||||
export const CustomIcon082: CustomIconComponent = CustomIcon082Base
|
||||
|
||||
const CustomIcon083Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon083Base = createLucideIcon('CustomIcon083', CustomIcon083Node)
|
||||
export const CustomIcon083: CustomIconComponent = CustomIcon083Base
|
||||
|
||||
const CustomIcon084Node: IconNode = [
|
||||
['path', { d: "M8 6h3v12H8zM13 6h3v12h-3z", fill: "currentColor" }],
|
||||
]
|
||||
const CustomIcon084Base = createLucideIcon('CustomIcon084', CustomIcon084Node)
|
||||
export const CustomIcon084: CustomIconComponent = CustomIcon084Base
|
||||
|
||||
const CustomIcon085Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon085Base = createLucideIcon('CustomIcon085', CustomIcon085Node)
|
||||
export const CustomIcon085: CustomIconComponent = CustomIcon085Base
|
||||
|
||||
const CustomIcon086Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon086Base = createLucideIcon('CustomIcon086', CustomIcon086Node)
|
||||
export const CustomIcon086: CustomIconComponent = CustomIcon086Base
|
||||
|
||||
const CustomIcon087Node: IconNode = [
|
||||
['path', { stroke: "url(#icon-gradient)", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z", fill: "none" }],
|
||||
]
|
||||
const CustomIcon087Base = createLucideIcon('CustomIcon087', CustomIcon087Node)
|
||||
export const CustomIcon087: CustomIconComponent = CustomIcon087Base
|
||||
|
||||
const CustomIcon088Node: IconNode = [
|
||||
['path', { stroke: "url(#icon-gradient)", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10", fill: "none" }],
|
||||
]
|
||||
const CustomIcon088Base = createLucideIcon('CustomIcon088', CustomIcon088Node)
|
||||
export const CustomIcon088: CustomIconComponent = CustomIcon088Base
|
||||
|
||||
const CustomIcon089Node: IconNode = [
|
||||
['path', { stroke: "url(#icon-gradient)", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z", fill: "none" }],
|
||||
]
|
||||
const CustomIcon089Base = createLucideIcon('CustomIcon089', CustomIcon089Node)
|
||||
export const CustomIcon089: CustomIconComponent = CustomIcon089Base
|
||||
|
||||
const CustomIcon090Node: IconNode = [
|
||||
['path', { stroke: "url(#icon-gradient)", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z", fill: "none" }],
|
||||
]
|
||||
const CustomIcon090Base = createLucideIcon('CustomIcon090', CustomIcon090Node)
|
||||
export const CustomIcon090: CustomIconComponent = CustomIcon090Base
|
||||
|
||||
const CustomIcon091Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon091Base = createLucideIcon('CustomIcon091', CustomIcon091Node)
|
||||
export const CustomIcon091: CustomIconComponent = CustomIcon091Base
|
||||
|
||||
const CustomIcon092Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon092Base = createLucideIcon('CustomIcon092', CustomIcon092Node)
|
||||
export const CustomIcon092: CustomIconComponent = CustomIcon092Base
|
||||
|
||||
const CustomIcon093Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon093Base = createLucideIcon('CustomIcon093', CustomIcon093Node)
|
||||
export const CustomIcon093: CustomIconComponent = CustomIcon093Base
|
||||
|
||||
const CustomIcon094Node: IconNode = [
|
||||
['path', { fillRule: "evenodd", d: "M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z", clipRule: "evenodd", fill: "currentColor" }],
|
||||
]
|
||||
const CustomIcon094Base = createLucideIcon('CustomIcon094', CustomIcon094Node)
|
||||
export const CustomIcon094: CustomIconComponent = CustomIcon094Base
|
||||
|
||||
const CustomIcon095Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon095Base = createLucideIcon('CustomIcon095', CustomIcon095Node)
|
||||
export const CustomIcon095: CustomIconComponent = CustomIcon095Base
|
||||
|
||||
const CustomIcon096Node: IconNode = [
|
||||
['path', { stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M12 9v4m0 4h.01M5.5 19h13a1 1 0 00.87-1.5l-6.5-11.5a1 1 0 00-1.74 0L4.63 17.5A1 1 0 005.5 19z", fill: "none" }],
|
||||
]
|
||||
const CustomIcon096Base = createLucideIcon('CustomIcon096', CustomIcon096Node)
|
||||
export const CustomIcon096: CustomIconComponent = CustomIcon096Base
|
||||
|
||||
const CustomIcon097Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M12 9v4m0 4h.01M10.29 3.86l-8.18 14.4A1 1 0 003 20h18a1 1 0 00.89-1.74l-8.18-14.4a1 1 0 00-1.74 0z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon097Base = createLucideIcon('CustomIcon097', CustomIcon097Node)
|
||||
export const CustomIcon097: CustomIconComponent = CustomIcon097Base
|
||||
|
||||
const CustomIcon098Node: IconNode = [
|
||||
['path', { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", d: "M13 16h-1v-4h-1m1-4h.01M12 3a9 9 0 100 18 9 9 0 000-18z", fill: "none", stroke: "currentColor" }],
|
||||
]
|
||||
const CustomIcon098Base = createLucideIcon('CustomIcon098', CustomIcon098Node)
|
||||
export const CustomIcon098: CustomIconComponent = CustomIcon098Base
|
||||
|
||||
export const IconGradientDefs = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(function IconGradientDefs(props, ref) {
|
||||
return (
|
||||
<svg ref={ref} {...props}>
|
||||
<defs>
|
||||
<linearGradient id="icon-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#06b6d4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
|
||||
export const customIcons = {
|
||||
close: CustomIcon001,
|
||||
edit: CustomIcon002,
|
||||
trash: CustomIcon003,
|
||||
check: CustomIcon004,
|
||||
refresh: CustomIcon005,
|
||||
plus: CustomIcon006,
|
||||
chevronDown: CustomIcon007,
|
||||
mic: CustomIcon008,
|
||||
bolt: CustomIcon009,
|
||||
image: CustomIcon010,
|
||||
sparkles: CustomIcon011,
|
||||
closeSm: CustomIcon012,
|
||||
play: CustomIcon013,
|
||||
sparklesAlt: CustomIcon014,
|
||||
chevronRight: CustomIcon015,
|
||||
alert: CustomIcon016,
|
||||
pause: CustomIcon017,
|
||||
folderCards: CustomIcon018,
|
||||
upload: CustomIcon019,
|
||||
imageAlt: CustomIcon020,
|
||||
user: CustomIcon021,
|
||||
copy: CustomIcon022,
|
||||
checkSolid: CustomIcon023,
|
||||
video: CustomIcon024,
|
||||
checkSm: CustomIcon025,
|
||||
plusAlt: CustomIcon026,
|
||||
editSquare: CustomIcon027,
|
||||
checkTiny: CustomIcon028,
|
||||
info: CustomIcon029,
|
||||
searchPlus: CustomIcon030,
|
||||
checkXs: CustomIcon031,
|
||||
menu: CustomIcon032,
|
||||
eye: CustomIcon033,
|
||||
eyeOff: CustomIcon034,
|
||||
checkDot: CustomIcon035,
|
||||
bookOpen: CustomIcon036,
|
||||
chevronLeft: CustomIcon037,
|
||||
package: CustomIcon038,
|
||||
idea: CustomIcon039,
|
||||
userAlt: CustomIcon040,
|
||||
globe2: CustomIcon041,
|
||||
checkMicro: CustomIcon042,
|
||||
imagePreview: CustomIcon043,
|
||||
fileText: CustomIcon044,
|
||||
micOutline: CustomIcon045,
|
||||
loader: CustomIcon046,
|
||||
cube: CustomIcon047,
|
||||
bookmark: CustomIcon048,
|
||||
settingsHex: CustomIcon049,
|
||||
audioWave: CustomIcon050,
|
||||
externalLink: CustomIcon051,
|
||||
settingsHexAlt: CustomIcon052,
|
||||
receipt: CustomIcon053,
|
||||
logout: CustomIcon054,
|
||||
filter: CustomIcon055,
|
||||
minus: CustomIcon056,
|
||||
file: CustomIcon057,
|
||||
monitor: CustomIcon058,
|
||||
coins: CustomIcon059,
|
||||
arrowRight: CustomIcon060,
|
||||
settingsHexMinor: CustomIcon061,
|
||||
diamond: CustomIcon062,
|
||||
ideaAlt: CustomIcon063,
|
||||
imageLandscape: CustomIcon064,
|
||||
lock: CustomIcon065,
|
||||
imageEdit: CustomIcon066,
|
||||
closeSolid: CustomIcon067,
|
||||
fileFold: CustomIcon068,
|
||||
userCircle: CustomIcon069,
|
||||
volumeOff: CustomIcon070,
|
||||
arrowRightWide: CustomIcon071,
|
||||
chart: CustomIcon072,
|
||||
videoAlt: CustomIcon073,
|
||||
closeMd: CustomIcon074,
|
||||
videoWide: CustomIcon075,
|
||||
chevronRightMd: CustomIcon076,
|
||||
plusMd: CustomIcon077,
|
||||
trashAlt: CustomIcon078,
|
||||
chevronUp: CustomIcon079,
|
||||
wandOff: CustomIcon080,
|
||||
playCircle: CustomIcon081,
|
||||
clipboardCheck: CustomIcon082,
|
||||
cloudUpload: CustomIcon083,
|
||||
pauseSolid: CustomIcon084,
|
||||
download: CustomIcon085,
|
||||
folder: CustomIcon086,
|
||||
statsBarGradient: CustomIcon087,
|
||||
statsEpisodeGradient: CustomIcon088,
|
||||
statsImageGradient: CustomIcon089,
|
||||
statsVideoGradient: CustomIcon090,
|
||||
statsBar: CustomIcon091,
|
||||
clock: CustomIcon092,
|
||||
search: CustomIcon093,
|
||||
badgeCheck: CustomIcon094,
|
||||
searchAdd: CustomIcon095,
|
||||
alertSolid: CustomIcon096,
|
||||
alertOutline: CustomIcon097,
|
||||
infoCircle: CustomIcon098,
|
||||
} as const
|
||||
5
src/components/ui/icons/index.ts
Normal file
5
src/components/ui/icons/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { AppIcon, type AppIconProps } from './AppIcon'
|
||||
export { iconRegistry, type AppIconName } from './registry'
|
||||
export { IconGradientDefs } from './custom'
|
||||
export { RatioPreviewIcon, type RatioPreviewIconProps } from './RatioPreviewIcon'
|
||||
export type { LucideIcon } from 'lucide-react'
|
||||
191
src/components/ui/icons/registry.ts
Normal file
191
src/components/ui/icons/registry.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
ArrowDownCircle,
|
||||
ArrowRight,
|
||||
AudioLines,
|
||||
BadgeCheck,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Bookmark,
|
||||
Box,
|
||||
Brain,
|
||||
ChartColumn,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
CircleUser,
|
||||
Clapperboard,
|
||||
ClipboardCheck,
|
||||
Clock3,
|
||||
CloudUpload,
|
||||
Coins,
|
||||
Copy,
|
||||
Cpu,
|
||||
Diamond,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Film,
|
||||
Filter,
|
||||
Folder,
|
||||
FolderHeart,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
GripVertical,
|
||||
Image,
|
||||
ImagePlus,
|
||||
Info,
|
||||
Lightbulb,
|
||||
Link2,
|
||||
Loader2,
|
||||
Lock,
|
||||
LogOut,
|
||||
Menu,
|
||||
Mic,
|
||||
Minus,
|
||||
Monitor,
|
||||
Pause,
|
||||
Pencil,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Unplug,
|
||||
Undo2,
|
||||
Upload,
|
||||
UserRound,
|
||||
UserRoundCog,
|
||||
UsersRound,
|
||||
Video,
|
||||
VolumeX,
|
||||
WandSparkles,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { customIcons } from './custom'
|
||||
|
||||
export const iconRegistry = {
|
||||
...customIcons,
|
||||
globe: Globe,
|
||||
folderHeart: FolderHeart,
|
||||
unplug: Unplug,
|
||||
userRoundCog: UserRoundCog,
|
||||
close: X,
|
||||
closeSm: X,
|
||||
closeMd: X,
|
||||
closeSolid: X,
|
||||
edit: Pencil,
|
||||
editSquare: SquarePen,
|
||||
trash: Trash2,
|
||||
trashAlt: Trash2,
|
||||
check: Check,
|
||||
checkSolid: Check,
|
||||
checkSm: Check,
|
||||
checkTiny: Check,
|
||||
checkXs: Check,
|
||||
checkDot: Check,
|
||||
checkMicro: Check,
|
||||
refresh: RefreshCw,
|
||||
plus: Plus,
|
||||
plusAlt: Plus,
|
||||
plusMd: Plus,
|
||||
chevronDown: ChevronDown,
|
||||
chevronRight: ChevronRight,
|
||||
chevronRightMd: ChevronRight,
|
||||
chevronLeft: ChevronLeft,
|
||||
chevronUp: ChevronUp,
|
||||
mic: Mic,
|
||||
micOutline: Mic,
|
||||
bolt: Zap,
|
||||
image: Image,
|
||||
imageAlt: Image,
|
||||
imagePreview: Image,
|
||||
imageEdit: ImagePlus,
|
||||
imageLandscape: Image,
|
||||
video: Video,
|
||||
videoAlt: Video,
|
||||
videoWide: Video,
|
||||
sparkles: Sparkles,
|
||||
sparklesAlt: WandSparkles,
|
||||
alert: TriangleAlert,
|
||||
alertSolid: TriangleAlert,
|
||||
alertOutline: TriangleAlert,
|
||||
pause: Pause,
|
||||
pauseSolid: Pause,
|
||||
play: Play,
|
||||
playCircle: PlayCircle,
|
||||
search: Search,
|
||||
searchAdd: Search,
|
||||
searchPlus: Search,
|
||||
info: Info,
|
||||
infoCircle: Info,
|
||||
folder: Folder,
|
||||
folderCards: Folder,
|
||||
upload: Upload,
|
||||
undo: Undo2,
|
||||
copy: Copy,
|
||||
user: UserRound,
|
||||
userAlt: UserRound,
|
||||
usersRound: UsersRound,
|
||||
userCircle: CircleUser,
|
||||
package: Box,
|
||||
cube: Box,
|
||||
idea: Lightbulb,
|
||||
ideaAlt: Lightbulb,
|
||||
globe2: Globe,
|
||||
file: FileText,
|
||||
fileText: FileText,
|
||||
fileFold: FileText,
|
||||
eye: Eye,
|
||||
eyeOff: EyeOff,
|
||||
bookOpen: BookOpen,
|
||||
menu: Menu,
|
||||
loader: Loader2,
|
||||
settingsHex: Settings,
|
||||
settingsHexAlt: Settings,
|
||||
settingsHexMinor: Settings,
|
||||
audioWave: AudioLines,
|
||||
externalLink: ExternalLink,
|
||||
receipt: Receipt,
|
||||
download: Download,
|
||||
logout: LogOut,
|
||||
filter: Filter,
|
||||
minus: Minus,
|
||||
monitor: Monitor,
|
||||
coins: Coins,
|
||||
arrowRight: ArrowRight,
|
||||
arrowRightWide: ArrowRight,
|
||||
diamond: Diamond,
|
||||
lock: Lock,
|
||||
link: Link2,
|
||||
badgeCheck: BadgeCheck,
|
||||
cloudUpload: CloudUpload,
|
||||
clipboardCheck: ClipboardCheck,
|
||||
clock: Clock3,
|
||||
chart: ChartColumn,
|
||||
statsBar: ChartColumn,
|
||||
volumeOff: VolumeX,
|
||||
wandOff: WandSparkles,
|
||||
bookmark: Bookmark,
|
||||
arrowDownCircle: ArrowDownCircle,
|
||||
barChart: BarChart3,
|
||||
brain: Brain,
|
||||
clapperboard: Clapperboard,
|
||||
cpu: Cpu,
|
||||
film: Film,
|
||||
folderOpen: FolderOpen,
|
||||
gripVertical: GripVertical,
|
||||
} as const satisfies Record<string, LucideIcon>
|
||||
|
||||
export type AppIconName = keyof typeof iconRegistry
|
||||
587
src/components/ui/model-dropdown-innovative.tsx
Normal file
587
src/components/ui/model-dropdown-innovative.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import type { ModelCapabilityOption, CapabilityFieldDefinition } from './config-modals/ModelCapabilityDropdown'
|
||||
import type { CapabilityValue } from '@/lib/model-config-contract'
|
||||
export interface ModelDropdownTestProps {
|
||||
models: ModelCapabilityOption[]
|
||||
value: string | undefined
|
||||
onModelChange: (modelKey: string) => void
|
||||
capabilityFields: CapabilityFieldDefinition[]
|
||||
capabilityOverrides: Record<string, CapabilityValue>
|
||||
onCapabilityChange: (field: string, rawValue: string, sample: CapabilityValue) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const VIEWPORT_EDGE_GAP = 8
|
||||
const DEFAULT_MAX_HEIGHT = 400
|
||||
|
||||
function useDropdown(isOpen: boolean, setIsOpen: (val: boolean) => void, alignRight: boolean = false) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP
|
||||
const spaceAbove = rect.top - VIEWPORT_EDGE_GAP
|
||||
|
||||
let openUpward = false
|
||||
let currentMaxHeight = DEFAULT_MAX_HEIGHT
|
||||
|
||||
if (spaceBelow < 250 && spaceAbove > spaceBelow) {
|
||||
openUpward = true
|
||||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)
|
||||
} else {
|
||||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)
|
||||
}
|
||||
|
||||
const width = Math.max(rect.width, 240)
|
||||
let left = rect.left
|
||||
if (alignRight) {
|
||||
left = rect.right - width
|
||||
}
|
||||
|
||||
setPanelStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width,
|
||||
maxHeight: currentMaxHeight,
|
||||
...(openUpward
|
||||
? { bottom: viewportHeight - rect.top + 6 }
|
||||
: { top: rect.bottom + 6 }),
|
||||
zIndex: 9999
|
||||
})
|
||||
}, [alignRight])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [setIsOpen])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return
|
||||
updatePosition()
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
}
|
||||
}, [isOpen, updatePosition])
|
||||
|
||||
return { triggerRef, panelRef, panelStyle }
|
||||
}
|
||||
|
||||
function resolveParamSummary(fields: CapabilityFieldDefinition[], overrides: Record<string, CapabilityValue>) {
|
||||
return fields.map(def => {
|
||||
const val = overrides[def.field] !== undefined ? String(overrides[def.field]) : String(def.options[0] || '')
|
||||
if (def.field === 'duration') return `${val}s`
|
||||
return val
|
||||
}).filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// V6: The Split Toolbar (Deconstructed Controls)
|
||||
// Breaks the monolithic dropdown into two separate context actions. No massive popover.
|
||||
// ============================================================================
|
||||
export function ModelInnovativeV6(props: ModelDropdownTestProps) {
|
||||
const [modelOpen, setModelOpen] = useState(false)
|
||||
const [paramOpen, setParamOpen] = useState(false)
|
||||
|
||||
const { triggerRef: modelTrigger, panelRef: modelPanel, panelStyle: modelStyle } = useDropdown(modelOpen, setModelOpen)
|
||||
const { triggerRef: paramTrigger, panelRef: paramPanel, panelStyle: paramStyle } = useDropdown(paramOpen, setParamOpen, true)
|
||||
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
const summary = resolveParamSummary(props.capabilityFields, props.capabilityOverrides)
|
||||
|
||||
return (
|
||||
<div className="flex items-center bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] rounded-xl shadow-sm backdrop-blur-md">
|
||||
{/* Left Button: Model Selection */}
|
||||
<button
|
||||
ref={modelTrigger}
|
||||
onClick={() => { setModelOpen(!modelOpen); setParamOpen(false) }}
|
||||
className={`flex-1 flex items-center justify-between px-4 py-3 transition-colors rounded-l-xl hover:bg-black/5 dark:hover:bg-white/5 ${modelOpen ? 'bg-black/5 dark:bg-white/5' : ''}`}
|
||||
>
|
||||
<div className="flex flex-col items-start min-w-0 pr-2">
|
||||
<span className="text-[11px] font-bold text-[var(--glass-text-tertiary)] uppercase tracking-wider mb-0.5">模型 Model</span>
|
||||
<span className="text-[14px] font-semibold text-[var(--glass-text-primary)] truncate">
|
||||
{activeModel ? activeModel.label : props.placeholder}
|
||||
</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className="w-4 h-4 text-[var(--glass-text-tertiary)] shrink-0" />
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-[1px] h-10 bg-[var(--glass-stroke-base)]" />
|
||||
|
||||
{/* Right Button: Param Configuration */}
|
||||
<button
|
||||
ref={paramTrigger}
|
||||
onClick={() => { setParamOpen(!paramOpen); setModelOpen(false) }}
|
||||
className={`flex-1 flex items-center justify-between px-4 py-3 transition-colors rounded-r-xl hover:bg-black/5 dark:hover:bg-white/5 ${paramOpen ? 'bg-black/5 dark:bg-white/5' : ''}`}
|
||||
disabled={props.capabilityFields.length === 0}
|
||||
>
|
||||
<div className="flex flex-col items-start min-w-0 pr-2">
|
||||
<span className="text-[11px] font-bold text-[var(--glass-text-tertiary)] uppercase tracking-wider mb-0.5">参数 Params</span>
|
||||
<span className={`text-[14px] font-semibold truncate ${props.capabilityFields.length === 0 ? 'text-[var(--glass-text-tertiary)]' : 'text-blue-500'}`}>
|
||||
{props.capabilityFields.length === 0 ? '不可配置' : (summary || '配置')}
|
||||
</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className="w-4 h-4 text-[var(--glass-text-tertiary)] shrink-0" />
|
||||
</button>
|
||||
|
||||
{/* Portals */}
|
||||
{modelOpen && createPortal(
|
||||
<div ref={modelPanel} style={modelStyle} className="glass-surface-modal rounded-xl shadow-lg border border-[var(--glass-stroke-base)] p-2">
|
||||
{props.models.map(m => (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => { props.onModelChange(m.value); setModelOpen(false) }}
|
||||
className="w-full text-left px-3 py-2.5 rounded-lg hover:bg-[var(--glass-bg-hover)] flex items-center justify-between transition-colors"
|
||||
>
|
||||
<span className="text-[14px] font-medium text-[var(--glass-text-primary)]">{m.label}</span>
|
||||
{m.value === props.value && <AppIcon name="check" className="w-4 h-4 text-blue-500" />}
|
||||
</button>
|
||||
))}
|
||||
</div>, document.body
|
||||
)}
|
||||
{paramOpen && props.capabilityFields.length > 0 && createPortal(
|
||||
<div ref={paramPanel} style={paramStyle} className="glass-surface-modal rounded-xl shadow-lg border border-[var(--glass-stroke-base)] p-4 space-y-4">
|
||||
{props.capabilityFields.map(field => {
|
||||
const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')
|
||||
return (
|
||||
<div key={field.field}>
|
||||
<div className="text-[12px] font-medium text-[var(--glass-text-secondary)] mb-2">{field.label || field.field}</div>
|
||||
<div className="flex gap-2">
|
||||
{field.options.map(opt => {
|
||||
const s = String(opt)
|
||||
const active = s === val
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}
|
||||
className={`flex-1 px-2 py-1.5 text-[13px] rounded-lg transition-colors border ${active ? 'bg-blue-50/50 dark:bg-blue-900/30 border-blue-500 text-blue-600 dark:text-blue-400 font-semibold' : 'bg-transparent border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] hover:hover:bg-[var(--glass-bg-hover)]'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>, document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// V7: The Inline Canvas Expandable (No Overlays, Document Flow)
|
||||
// Pushes content down naturally. Perfect for form wizards.
|
||||
// ============================================================================
|
||||
export function ModelInnovativeV7(props: ModelDropdownTestProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--glass-bg-surface-strong)] rounded-2xl border border-[var(--glass-stroke-subtle)] overflow-hidden transition-all duration-300 shadow-sm">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between p-4 bg-transparent outline-none focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center text-blue-600 dark:text-blue-400">
|
||||
<AppIcon name="cpu" className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-left flex flex-col">
|
||||
<span className="text-[15px] font-bold text-[var(--glass-text-primary)]">
|
||||
{activeModel ? activeModel.label : '未选择模型'}
|
||||
</span>
|
||||
<span className="text-[12px] text-[var(--glass-text-tertiary)] mt-0.5">
|
||||
展开以修改模型或参数设置
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center bg-[var(--glass-bg-muted)] transition-transform duration-300 ${isExpanded ? 'rotate-180' : ''}`}>
|
||||
<AppIcon name="chevronDown" className="w-4 h-4 text-[var(--glass-text-secondary)]" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className={`transition-all duration-300 ease-in-out ${isExpanded ? 'max-h-[800px] opacity-100' : 'max-h-0 opacity-0'} overflow-hidden`}>
|
||||
<div className="p-4 pt-0 border-t border-[var(--glass-stroke-base)] mt-2 mx-4">
|
||||
<div className="mt-4 mb-2 text-[12px] font-bold uppercase tracking-wider text-[var(--glass-text-secondary)]">1. 选择模型</div>
|
||||
<div className="grid grid-cols-2 gap-2 mb-6">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => props.onModelChange(m.value)}
|
||||
className={`p-3 text-left rounded-xl transition-colors border ${active ? 'bg-blue-50/80 dark:bg-blue-900/40 border-blue-400 shadow-[0_0_12px_rgba(59,130,246,0.15)]' : 'bg-[var(--glass-bg-base)] border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-active)]'}`}
|
||||
>
|
||||
<div className={`text-[13px] font-semibold mb-1 ${active ? 'text-blue-600 dark:text-blue-400' : 'text-[var(--glass-text-primary)]'}`}>{m.label}</div>
|
||||
{m.providerName && <div className="text-[10px] text-[var(--glass-text-tertiary)]">{m.providerName}</div>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{props.capabilityFields.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2 text-[12px] font-bold uppercase tracking-wider text-[var(--glass-text-secondary)]">2. 参数微调</div>
|
||||
<div className="space-y-4 bg-[var(--glass-bg-base)] p-4 rounded-xl border border-[var(--glass-stroke-subtle)]">
|
||||
{props.capabilityFields.map(field => {
|
||||
const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')
|
||||
return (
|
||||
<div key={field.field} className="flex items-center justify-between gap-4">
|
||||
<span className="text-[13px] font-medium text-[var(--glass-text-primary)] shrink-0">{field.label || field.field}</span>
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
{field.options.map(opt => {
|
||||
const s = String(opt)
|
||||
const active = s === val
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}
|
||||
className={`px-3 py-1 text-[12px] transition-all rounded-md ${active ? 'bg-[var(--glass-text-primary)] text-[var(--glass-bg-base)] shadow-md font-bold' : 'bg-[var(--glass-bg-surface-strong)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-hover)]'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// V8: The Pro Centered Modal (Context Shift)
|
||||
// Clicking opens a spacious, distraction-free modal dialog. Left-right layout.
|
||||
// ============================================================================
|
||||
export function ModelInnovativeV8(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-full flex items-center justify-between px-5 py-3 rounded-xl bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AppIcon name="settingsHex" className="w-5 h-5 text-[var(--glass-text-secondary)]" />
|
||||
<span className="text-[15px] font-medium text-[var(--glass-text-primary)]">
|
||||
{activeModel ? activeModel.label : '配置模型...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[12px] font-bold text-blue-500 bg-blue-500/10 px-3 py-1 rounded-full uppercase tracking-widest">
|
||||
编辑
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setIsOpen(false)} />
|
||||
<div className="relative w-full max-w-3xl h-[500px] flex rounded-2xl bg-[var(--glass-bg-base)] border border-[var(--glass-stroke-subtle)] shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Left: Models List */}
|
||||
<div className="w-1/2 flex flex-col border-r border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-surface)]">
|
||||
<div className="p-5 border-b border-[var(--glass-stroke-subtle)] flex items-center justify-between">
|
||||
<h2 className="text-[18px] font-bold text-[var(--glass-text-primary)]">模型库</h2>
|
||||
<span className="text-[12px] text-[var(--glass-text-tertiary)]">包含 {props.models.length} 项</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => props.onModelChange(m.value)}
|
||||
className={`w-full text-left p-4 rounded-xl transition-colors border ${active ? 'bg-blue-500 shadow-[0_4px_12px_rgba(59,130,246,0.3)] border-transparent' : 'bg-transparent border-transparent hover:bg-black/5 dark:hover:bg-white/5'}`}
|
||||
>
|
||||
<div className={`text-[15px] font-bold ${active ? 'text-white' : 'text-[var(--glass-text-primary)]'}`}>{m.label}</div>
|
||||
{m.providerName && <div className={`text-[12px] mt-1 ${active ? 'text-blue-100' : 'text-[var(--glass-text-tertiary)]'}`}>{m.providerName}</div>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: Params Configuration */}
|
||||
<div className="w-1/2 flex flex-col bg-[var(--glass-bg-base)]">
|
||||
<div className="p-5 border-b border-[var(--glass-stroke-subtle)] flex items-center justify-between">
|
||||
<h2 className="text-[18px] font-bold text-[var(--glass-text-primary)]">参数设置</h2>
|
||||
<button onClick={() => setIsOpen(false)} className="p-1 rounded-full hover:bg-[var(--glass-bg-hover)]">
|
||||
<AppIcon name="close" className="w-5 h-5 text-[var(--glass-text-secondary)]" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||||
{props.capabilityFields.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center text-[var(--glass-text-tertiary)] gap-4 opacity-70">
|
||||
<AppIcon name="info" className="w-10 h-10" />
|
||||
<p>当前模型无可用参数</p>
|
||||
</div>
|
||||
) : (
|
||||
props.capabilityFields.map(field => {
|
||||
const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')
|
||||
return (
|
||||
<div key={field.field} className="space-y-4">
|
||||
<div className="text-[14px] font-bold text-[var(--glass-text-secondary)] uppercase tracking-widest">{field.label || field.field}</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{field.options.map(opt => {
|
||||
const s = String(opt)
|
||||
const active = s === val
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}
|
||||
className={`p-3 text-[14px] text-center rounded-xl transition-all border ${active ? 'bg-[var(--glass-text-primary)] text-[var(--glass-bg-base)] border-[var(--glass-text-primary)]' : 'bg-transparent text-[var(--glass-text-primary)] border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-active)]'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-surface-strong)] flex justify-end">
|
||||
<button onClick={() => setIsOpen(false)} className="px-6 py-2.5 rounded-lg bg-blue-600 text-white font-semibold hover:bg-blue-500 shadow-md">
|
||||
确认应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// V9: The Drill-Down Popover (Nested Navigation)
|
||||
// Click Model -> Popover shows Model List -> Click "Params" -> View shifts sideways inside popover.
|
||||
// ============================================================================
|
||||
export function ModelInnovativeV9(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [view, setView] = useState<'models' | 'params'>('models')
|
||||
const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
|
||||
// When re-opening, reset view
|
||||
useEffect(() => {
|
||||
if (isOpen) setView('models')
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex items-center justify-between w-full p-2 rounded-lg bg-[var(--glass-bg-surface)] border ${isOpen ? 'border-[#ff6b6b] ring-1 ring-[#ff6b6b]/20 shadow-[0_4px_16px_rgba(255,107,107,0.1)]' : 'border-[var(--glass-stroke-base)] group hover:border-[var(--glass-stroke-active)]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded shrink-0 bg-[#ff6b6b]/10 flex items-center justify-center">
|
||||
<AppIcon name="sparkles" className="w-4 h-4 text-[#ff6b6b]" />
|
||||
</div>
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="text-[13px] font-semibold text-[var(--glass-text-primary)]">{activeModel ? activeModel.label : '未选择'}</span>
|
||||
<span className="text-[11px] text-[var(--glass-text-tertiary)]">{props.capabilityFields.length} 项参数配置可设</span>
|
||||
</div>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className="w-4 h-4 mr-1 text-[var(--glass-text-tertiary)] transition-transform group-hover:text-[var(--glass-text-primary)]" />
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
<div ref={panelRef} style={panelStyle} className="glass-surface-modal rounded-xl shadow-xl border border-[var(--glass-stroke-subtle)] overflow-hidden bg-[var(--glass-bg-base)]">
|
||||
<div className={`flex w-[200%] transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] ${view === 'params' ? '-translate-x-1/2' : 'translate-x-0'}`}>
|
||||
{/* Page 1: Models */}
|
||||
<div className="w-1/2 flex flex-col max-h-[300px]">
|
||||
<div className="p-3 border-b border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-surface)] font-bold text-[13px] text-center">选择主要模型</div>
|
||||
<div className="overflow-y-auto flex-1 p-2 space-y-1">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<div key={m.value} className="flex gap-1 group">
|
||||
<button
|
||||
onClick={() => props.onModelChange(m.value)}
|
||||
className={`flex-1 flex items-center px-3 py-2 rounded-lg text-left transition-colors ${active ? 'bg-[#ff6b6b]/10 text-[#ff6b6b] font-bold' : 'hover:bg-[var(--glass-bg-hover)] text-[var(--glass-text-primary)]'}`}
|
||||
>
|
||||
<span className="text-[13px]">{m.label}</span>
|
||||
{active && <AppIcon name="check" className="w-3.5 h-3.5 ml-auto" />}
|
||||
</button>
|
||||
{active && props.capabilityFields.length > 0 && (
|
||||
<button
|
||||
onClick={() => setView('params')}
|
||||
className="px-2 py-2 w-[40px] flex items-center justify-center rounded-lg bg-[var(--glass-bg-surface-strong)] hover:bg-[var(--glass-bg-hover)] border border-[var(--glass-stroke-subtle)] text-[var(--glass-text-secondary)] shadow-sm"
|
||||
title="配置参数"
|
||||
>
|
||||
<AppIcon name="settingsHex" className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Page 2: Params */}
|
||||
<div className="w-1/2 flex flex-col max-h-[300px]">
|
||||
<div className="p-2 border-b border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-surface)] flex items-center">
|
||||
<button onClick={() => setView('models')} className="p-1 px-2 shrink-0 flex items-center gap-1 hover:bg-[var(--glass-bg-hover)] rounded font-medium text-[12px] text-[var(--glass-text-secondary)]">
|
||||
<AppIcon name="chevronDown" className="w-4 h-4 rotate-90" />
|
||||
返回
|
||||
</button>
|
||||
<div className="font-bold text-[13px] text-center flex-1 mr-8">参数配置</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-4 space-y-5">
|
||||
{props.capabilityFields.map(field => {
|
||||
const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')
|
||||
return (
|
||||
<div key={field.field} className="space-y-2">
|
||||
<div className="text-[12px] font-semibold text-[var(--glass-text-secondary)]">{field.label || field.field}</div>
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
{field.options.map(opt => {
|
||||
const s = String(opt)
|
||||
const active = s === val
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}
|
||||
className={`w-full p-2 text-[12px] text-center rounded-md border transition-all ${active ? 'bg-[#ff6b6b] text-white border-[#ff6b6b]' : 'bg-[var(--glass-bg-surface-strong)] border-[var(--glass-stroke-base)] text-[var(--glass-text-primary)] hover:border-[var(--glass-stroke-active)]'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// V10: Bottom Sheet Drawer (Mobile-inspired / Context Menu Bottom)
|
||||
// Triggers a drawer anchored to the bottom of the screen. Very tactile.
|
||||
// ============================================================================
|
||||
export function ModelInnovativeV10(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-full flex items-center justify-center gap-3 py-3 px-4 rounded-full bg-[var(--glass-bg-base)] border border-[var(--glass-text-primary)] hover:bg-[var(--glass-text-primary)] hover:text-[var(--glass-bg-base)] group transition-all text-[var(--glass-text-primary)] font-bold shadow-[0_4px_14px_rgba(0,0,0,0.1)]"
|
||||
>
|
||||
<AppIcon name="cpu" className="w-5 h-5 group-hover:animate-pulse" />
|
||||
<span>生成偏好: {activeModel ? activeModel.label : '点击选择'}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
<div className="fixed inset-0 z-[99999] flex flex-col justify-end">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-in fade-in duration-300"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="relative w-full max-w-4xl mx-auto bg-[var(--glass-bg-base)] border-t border-[var(--glass-stroke-active)] rounded-t-[32px] p-6 pb-12 shadow-[0_-10px_40px_rgba(0,0,0,0.2)] animate-in slide-in-from-bottom duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]">
|
||||
<div className="w-12 h-1.5 bg-[var(--glass-stroke-active)] rounded-full mx-auto mb-6" />
|
||||
|
||||
<div className="flex justify-between items-center mb-6 px-2">
|
||||
<h2 className="text-[24px] font-black text-[var(--glass-text-primary)] tracking-tight">配置生成偏好</h2>
|
||||
<button onClick={() => setIsOpen(false)} className="w-10 h-10 rounded-full bg-[var(--glass-bg-surface-strong)] flex items-center justify-center hover:bg-[var(--glass-bg-hover)]">
|
||||
<AppIcon name="close" className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 px-2">
|
||||
{/* Left: Models horizontally scrollable block */}
|
||||
<div className="w-full md:w-2/3">
|
||||
<h3 className="text-[14px] font-bold text-[var(--glass-text-secondary)] uppercase tracking-wider mb-4">核心模型选择</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => props.onModelChange(m.value)}
|
||||
className={`flex flex-col items-start p-4 rounded-[20px] transition-all border-2 text-left ${active ? 'border-[#3B82F6] bg-[#3B82F6]/5 shadow-[0_8px_20px_rgba(59,130,246,0.1)]' : 'border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] hover:border-[var(--glass-stroke-active)]'}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center mb-3 ${active ? 'bg-[#3B82F6] shadow-[0_4px_10px_rgba(59,130,246,0.3)]' : 'bg-[var(--glass-bg-base)] border border-[var(--glass-stroke-subtle)]'}`}>
|
||||
<AppIcon name="sparkles" className={`w-5 h-5 ${active ? 'text-white' : 'text-[var(--glass-text-tertiary)]'}`} />
|
||||
</div>
|
||||
<span className={`text-[15px] font-bold leading-tight ${active ? 'text-[#3B82F6]' : 'text-[var(--glass-text-primary)]'}`}>{m.label}</span>
|
||||
{m.providerName && <span className="text-[11px] font-medium text-[var(--glass-text-tertiary)] mt-1">{m.providerName}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Vertical params list */}
|
||||
<div className="w-full md:w-1/3 flex flex-col pt-2 md:pt-0 border-t md:border-t-0 md:border-l border-[var(--glass-stroke-subtle)] md:pl-8">
|
||||
<h3 className="text-[14px] font-bold text-[var(--glass-text-secondary)] uppercase tracking-wider mb-4">参数微调</h3>
|
||||
{props.capabilityFields.length === 0 ? (
|
||||
<div className="text-[var(--glass-text-tertiary)] text-[14px]">自动最佳配置应用中</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{props.capabilityFields.map(field => {
|
||||
const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')
|
||||
return (
|
||||
<div key={field.field}>
|
||||
<div className="text-[15px] font-bold text-[var(--glass-text-primary)] mb-3">{field.label || field.field}</div>
|
||||
<div className="flex bg-[var(--glass-bg-surface-strong)] p-1.5 rounded-[16px]">
|
||||
{field.options.map(opt => {
|
||||
const s = String(opt)
|
||||
const active = s === val
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}
|
||||
className={`flex-1 py-2 text-[14px] font-bold rounded-[12px] transition-all ${active ? 'bg-white dark:bg-black text-[var(--glass-text-primary)] shadow-md' : 'text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
366
src/components/ui/model-dropdown-ios.tsx
Normal file
366
src/components/ui/model-dropdown-ios.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import type { ModelCapabilityOption, CapabilityFieldDefinition } from './config-modals/ModelCapabilityDropdown'
|
||||
import type { CapabilityValue } from '@/lib/model-config-contract'
|
||||
|
||||
export interface ModelDropdownTestProps {
|
||||
models: ModelCapabilityOption[]
|
||||
value: string | undefined
|
||||
onModelChange: (modelKey: string) => void
|
||||
capabilityFields: CapabilityFieldDefinition[]
|
||||
capabilityOverrides: Record<string, CapabilityValue>
|
||||
onCapabilityChange: (field: string, rawValue: string, sample: CapabilityValue) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const VIEWPORT_EDGE_GAP = 8
|
||||
const DEFAULT_MAX_HEIGHT = 450
|
||||
|
||||
function useDropdown(isOpen: boolean, setIsOpen: (val: boolean) => void) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP
|
||||
const spaceAbove = rect.top - VIEWPORT_EDGE_GAP
|
||||
|
||||
let openUpward = false
|
||||
let currentMaxHeight = DEFAULT_MAX_HEIGHT
|
||||
|
||||
if (spaceBelow < 250 && spaceAbove > spaceBelow) {
|
||||
openUpward = true
|
||||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)
|
||||
} else {
|
||||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)
|
||||
}
|
||||
|
||||
setPanelStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 320),
|
||||
maxHeight: currentMaxHeight,
|
||||
...(openUpward
|
||||
? { bottom: viewportHeight - rect.top + 6 }
|
||||
: { top: rect.bottom + 6 }),
|
||||
zIndex: 9999
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [setIsOpen])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return
|
||||
updatePosition()
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
}
|
||||
}, [isOpen, updatePosition])
|
||||
|
||||
return { triggerRef, panelRef, panelStyle }
|
||||
}
|
||||
|
||||
function resolveParamSummary(fields: CapabilityFieldDefinition[], overrides: Record<string, CapabilityValue>) {
|
||||
return fields.map(def => {
|
||||
const val = overrides[def.field] !== undefined ? String(overrides[def.field]) : String(def.options[0] || '')
|
||||
return val
|
||||
}).filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function DefaultParamsRenderer({ fields, overrides, onChange, className }: { fields: CapabilityFieldDefinition[], overrides: Record<string, CapabilityValue>, onChange: (field: string, rawValue: string, sample: CapabilityValue) => void, className?: string }) {
|
||||
if (fields.length === 0) return null;
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="text-[11px] font-bold text-[#8e8e93] px-1 pt-0.5 mb-2">参数配置</div>
|
||||
{fields.map(field => {
|
||||
const val = overrides[field.field] !== undefined ? String(overrides[field.field]) : String(field.options[0] || '')
|
||||
if (field.field === 'duration' || field.options.length >= 4) {
|
||||
return (
|
||||
<div key={field.field} className="flex items-center justify-between gap-4 px-1 py-1 relative group">
|
||||
<span className="text-[13px] font-semibold text-[var(--glass-text-secondary)] shrink-0">{field.label || field.field}</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={val}
|
||||
onChange={(e) => onChange(field.field, e.target.value, field.options[0])}
|
||||
className="appearance-none bg-transparent hover:bg-[#f2f2f7] dark:hover:bg-[#1c1c1e] text-[13px] font-bold text-[var(--glass-text-primary)] pl-3 pr-7 py-1 rounded-md transition-colors outline-none cursor-pointer border border-transparent"
|
||||
>
|
||||
{field.options.map(opt => <option key={String(opt)} value={String(opt)}>{String(opt)}</option>)}
|
||||
</select>
|
||||
<AppIcon name="chevronDown" className="w-3.5 h-3.5 text-[var(--glass-text-tertiary)] absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none group-hover:text-[var(--glass-text-primary)] transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={field.field} className="flex items-center justify-between gap-4 px-1 py-1">
|
||||
<span className="text-[13px] font-semibold text-[var(--glass-text-secondary)] shrink-0">{field.label || field.field}</span>
|
||||
<div className="flex bg-[#f2f2f7] dark:bg-[#1c1c1e] p-[3px] rounded-lg shadow-inner">
|
||||
{field.options.map(opt => {
|
||||
const s = String(opt)
|
||||
const active = s === val
|
||||
return (
|
||||
<button key={s} onClick={() => onChange(field.field, s, field.options[0])} className={`px-4 py-1.5 text-[12px] font-medium rounded-md transition-all ${active ? 'bg-white text-black dark:bg-[#2c2c2e] dark:text-white shadow-[0_3px_8px_rgba(0,0,0,0.12),0_3px_1px_rgba(0,0,0,0.04)] font-bold' : 'text-[#8e8e93] hover:text-[#3a3a3c] dark:hover:text-[#ebebf5]'}`}>
|
||||
{s}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// V1: Deep Glass Glow (绝对的文字发光与透明度)
|
||||
// 完美继承原 V6 的文字投影流派。去背景化。
|
||||
// ============================================================================
|
||||
export function IOSVariant1(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={triggerRef} onClick={() => setIsOpen(!isOpen)} className={`w-full h-[46px] px-4 rounded-[14px] bg-[var(--glass-bg-surface)] border transition-colors ${isOpen ? 'border-[var(--glass-tone-info-fg)]' : 'border-[var(--glass-stroke-subtle)] hover:border-[var(--glass-stroke-active)]'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[14px] text-[var(--glass-text-primary)]">{activeModel?.label || props.placeholder}</span>
|
||||
{activeModel?.providerName && (
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] whitespace-nowrap">
|
||||
{activeModel.providerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] text-[var(--glass-text-secondary)]">{resolveParamSummary(props.capabilityFields, props.capabilityOverrides)}</span>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180 text-[var(--glass-tone-info-fg)]' : 'text-[var(--glass-text-tertiary)]'} drop-shadow-[0_1px_3px_var(--glass-tone-info-bg)]`} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && createPortal(
|
||||
<div ref={panelRef} style={panelStyle} className="glass-surface-modal rounded-[20px] shadow-[0_8px_32px_rgba(0,0,0,0.1)] border border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-base)] flex flex-col p-2">
|
||||
<div className="overflow-y-auto max-h-[220px]">
|
||||
{props.models.map(m => (
|
||||
<button key={m.value} onClick={() => props.onModelChange(m.value)} className={`w-full text-left px-3 py-2.5 rounded-[12px] font-medium transition-colors hover:bg-[var(--glass-bg-hover)] ${m.value === props.value ? 'bg-[var(--glass-bg-surface-strong)]' : ''}`}>
|
||||
<span className={m.value === props.value ? 'text-[var(--glass-tone-info-fg)] font-bold drop-shadow-[0_1px_4px_var(--glass-tone-info-bg)]' : 'text-[var(--glass-text-primary)]'}>{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{props.capabilityFields.length > 0 && <div className="h-[1px] bg-[var(--glass-stroke-subtle)] mx-2 my-2" />}
|
||||
<DefaultParamsRenderer fields={props.capabilityFields} overrides={props.capabilityOverrides} onChange={props.onCapabilityChange} className="space-y-3 p-2" />
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// V2: Left Indicator Line (硬朗工业线段侧边提示)
|
||||
// 完美继承原 V7 的左侧工业级边框设计。
|
||||
// ============================================================================
|
||||
export function IOSVariant2(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={triggerRef} onClick={() => setIsOpen(!isOpen)} className="w-full h-[46px] px-4 rounded-[14px] bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-subtle)] flex items-center justify-between hover:border-[var(--glass-stroke-active)] transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[14px] text-[var(--glass-text-primary)]">{activeModel?.label || props.placeholder}</span>
|
||||
{activeModel?.providerName && (
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] whitespace-nowrap">
|
||||
{activeModel.providerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] text-[var(--glass-text-secondary)]">{resolveParamSummary(props.capabilityFields, props.capabilityOverrides)}</span>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180 text-[var(--glass-text-primary)]' : 'text-[var(--glass-text-tertiary)]'}`} />
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && createPortal(
|
||||
<div ref={panelRef} style={panelStyle} className="glass-surface-modal rounded-[20px] shadow-lg border border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-base)] flex flex-col pt-3 pb-2 overflow-hidden">
|
||||
<div className="overflow-y-auto max-h-[220px]">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<button key={m.value} onClick={() => props.onModelChange(m.value)} className={`w-full text-left px-5 py-2.5 transition-colors border-l-[3px] ${active ? 'border-[var(--glass-tone-info-fg)] bg-[var(--glass-bg-surface-strong)] text-[var(--glass-text-primary)] font-bold' : 'border-transparent text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-hover)]'}`}>
|
||||
{m.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{props.capabilityFields.length > 0 && <div className="h-[1px] bg-[var(--glass-stroke-subtle)] mx-4 my-3" />}
|
||||
<DefaultParamsRenderer fields={props.capabilityFields} overrides={props.capabilityOverrides} onChange={props.onCapabilityChange} className="space-y-4 px-5 pb-3" />
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// V3: Fusion (Indicator + Glow) 融合变体
|
||||
// 吸收了左边条指示 + 文字自身发光的完美合体。
|
||||
// ============================================================================
|
||||
export function IOSVariant3(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={triggerRef} onClick={() => setIsOpen(!isOpen)} className={`w-full h-[46px] px-4 rounded-[14px] bg-[var(--glass-bg-surface)] border transition-all duration-300 ${isOpen ? 'border-[var(--glass-tone-info-fg)] shadow-[0_0_8px_var(--glass-tone-info-bg)]' : 'border-[var(--glass-stroke-subtle)] hover:border-[var(--glass-stroke-active)]'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[14px] text-[var(--glass-text-primary)]">{activeModel?.label || props.placeholder}</span>
|
||||
{activeModel?.providerName && (
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] whitespace-nowrap">
|
||||
{activeModel.providerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] text-[var(--glass-text-secondary)]">{resolveParamSummary(props.capabilityFields, props.capabilityOverrides)}</span>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180 text-[var(--glass-tone-info-fg)]' : 'text-[var(--glass-text-tertiary)]'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && createPortal(
|
||||
<div ref={panelRef} style={panelStyle} className="glass-surface-modal rounded-[20px] shadow-[0_8px_32px_rgba(0,0,0,0.12)] border border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-base)] flex flex-col p-2 overflow-hidden">
|
||||
<div className="overflow-y-auto max-h-[220px]">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<button key={m.value} onClick={() => props.onModelChange(m.value)} className={`w-full text-left px-3 py-2.5 rounded-[8px] transition-all border-l-[3px] hover:bg-[var(--glass-bg-hover)] ${active ? 'bg-[var(--glass-tone-info-bg)]/10 border-[var(--glass-tone-info-fg)] shadow-[-4px_0_12px_var(--glass-tone-info-bg)]' : 'border-transparent text-[var(--glass-text-secondary)]'}`}>
|
||||
<span className={active ? 'text-[var(--glass-tone-info-fg)] font-bold drop-shadow-[0_1px_4px_var(--glass-tone-info-bg)] pl-1' : 'pl-1'}>
|
||||
{m.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{props.capabilityFields.length > 0 && <div className="h-[1px] bg-[var(--glass-stroke-subtle)] mx-2 my-3" />}
|
||||
<DefaultParamsRenderer fields={props.capabilityFields} overrides={props.capabilityOverrides} onChange={props.onCapabilityChange} className="space-y-4 px-2 pb-2" />
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// V4: Pill Active Marker + Glow (极简悬浮胶囊标示 + 文本色晕)
|
||||
// 原左侧边条缩编为一个极其悬浮的前置小药丸胶囊,十分精致高级。
|
||||
// ============================================================================
|
||||
export function IOSVariant4(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={triggerRef} onClick={() => setIsOpen(!isOpen)} className="w-full h-[46px] px-4 rounded-[14px] bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-subtle)] flex items-center justify-between hover:border-[var(--glass-stroke-active)] transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[14px] text-[var(--glass-text-primary)]">{activeModel?.label || props.placeholder}</span>
|
||||
{activeModel?.providerName && (
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] whitespace-nowrap">
|
||||
{activeModel.providerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] text-[var(--glass-text-secondary)]">{resolveParamSummary(props.capabilityFields, props.capabilityOverrides)}</span>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''} text-[var(--glass-text-tertiary)]`} />
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && createPortal(
|
||||
<div ref={panelRef} style={panelStyle} className="glass-surface-modal rounded-[20px] shadow-xl border border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-base)] flex flex-col p-2 overflow-hidden">
|
||||
<div className="overflow-y-auto max-h-[220px]">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<button key={m.value} onClick={() => props.onModelChange(m.value)} className="w-full relative text-left px-5 py-2.5 rounded-[10px] transition-colors hover:bg-[var(--glass-bg-hover)]">
|
||||
{active && (
|
||||
<div className="absolute left-1.5 top-1/2 -translate-y-1/2 w-1 h-3/5 rounded-full bg-[var(--glass-tone-info-fg)] shadow-[0_0_8px_var(--glass-tone-info-bg)]" />
|
||||
)}
|
||||
<span className={active ? 'text-[var(--glass-tone-info-fg)] font-bold drop-shadow-[0_1px_4px_var(--glass-tone-info-bg)]' : 'text-[var(--glass-text-secondary)] font-medium'}>
|
||||
{m.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{props.capabilityFields.length > 0 && <div className="h-[1px] bg-[var(--glass-stroke-subtle)] mx-3 my-3" />}
|
||||
<DefaultParamsRenderer fields={props.capabilityFields} overrides={props.capabilityOverrides} onChange={props.onCapabilityChange} className="space-y-4 px-3 pb-3" />
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// V5: Underline Glow (下划弧度标示 + 全文字发光)
|
||||
// 在保留 Glow 特性的前提下,将纯粹的左侧指示器转移到了内嵌胶囊底部极具设计感的下划线。
|
||||
// ============================================================================
|
||||
export function IOSVariant5(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={triggerRef} onClick={() => setIsOpen(!isOpen)} className="w-full h-[46px] px-4 rounded-[14px] bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-subtle)] flex items-center justify-between hover:bg-[var(--glass-bg-surface-strong)] transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[14px] text-[var(--glass-text-primary)]">{activeModel?.label || props.placeholder}</span>
|
||||
{activeModel?.providerName && (
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] whitespace-nowrap">
|
||||
{activeModel.providerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] text-[var(--glass-text-secondary)]">{resolveParamSummary(props.capabilityFields, props.capabilityOverrides)}</span>
|
||||
<AppIcon name="chevronDown" className="w-4 h-4 text-[var(--glass-text-tertiary)]" />
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && createPortal(
|
||||
<div ref={panelRef} style={panelStyle} className="glass-surface-modal rounded-[20px] shadow-lg border border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-base)] flex flex-col p-2 overflow-hidden">
|
||||
<div className="overflow-y-auto max-h-[220px]">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<button key={m.value} onClick={() => props.onModelChange(m.value)} className={`w-full text-left px-4 py-3 rounded-[12px] transition-all border-b-[2px] ${active ? 'border-[var(--glass-tone-info-fg)] bg-[var(--glass-bg-surface-strong)] shadow-[0_4px_16px_var(--glass-tone-info-bg)]' : 'border-transparent text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-hover)]'}`}>
|
||||
<span className={active ? 'text-[var(--glass-tone-info-fg)] font-bold drop-shadow-[0_1px_4px_var(--glass-tone-info-bg)]' : 'font-medium'}>
|
||||
{m.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{props.capabilityFields.length > 0 && <div className="h-[1px] bg-[var(--glass-stroke-subtle)] mx-3 my-3" />}
|
||||
<DefaultParamsRenderer fields={props.capabilityFields} overrides={props.capabilityOverrides} onChange={props.onCapabilityChange} className="space-y-4 px-3 pb-2" />
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
542
src/components/ui/model-dropdown-variants.tsx
Normal file
542
src/components/ui/model-dropdown-variants.tsx
Normal file
@@ -0,0 +1,542 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import type { ModelCapabilityOption, CapabilityFieldDefinition } from './config-modals/ModelCapabilityDropdown'
|
||||
import type { CapabilityValue } from '@/lib/model-config-contract'
|
||||
export interface ModelDropdownTestProps {
|
||||
models: ModelCapabilityOption[]
|
||||
value: string | undefined
|
||||
onModelChange: (modelKey: string) => void
|
||||
capabilityFields: CapabilityFieldDefinition[]
|
||||
capabilityOverrides: Record<string, CapabilityValue>
|
||||
onCapabilityChange: (field: string, rawValue: string, sample: CapabilityValue) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const VIEWPORT_EDGE_GAP = 8
|
||||
const DEFAULT_MAX_HEIGHT = 400
|
||||
|
||||
function useDropdown(isOpen: boolean, setIsOpen: (val: boolean) => void) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP
|
||||
const spaceAbove = rect.top - VIEWPORT_EDGE_GAP
|
||||
|
||||
let openUpward = false
|
||||
let currentMaxHeight = DEFAULT_MAX_HEIGHT
|
||||
|
||||
if (spaceBelow < 250 && spaceAbove > spaceBelow) {
|
||||
openUpward = true
|
||||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)
|
||||
} else {
|
||||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)
|
||||
}
|
||||
|
||||
setPanelStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 320),
|
||||
maxHeight: currentMaxHeight,
|
||||
...(openUpward
|
||||
? { bottom: viewportHeight - rect.top + 6 }
|
||||
: { top: rect.bottom + 6 }),
|
||||
zIndex: 9999
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [setIsOpen])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return
|
||||
updatePosition()
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
}
|
||||
}, [isOpen, updatePosition])
|
||||
|
||||
return { triggerRef, panelRef, panelStyle }
|
||||
}
|
||||
|
||||
function resolveParamSummary(fields: CapabilityFieldDefinition[], overrides: Record<string, CapabilityValue>) {
|
||||
return fields.map(def => {
|
||||
const val = overrides[def.field] !== undefined ? String(overrides[def.field]) : String(def.options[0] || '')
|
||||
if (def.field === 'duration') return `${val}s`
|
||||
return val
|
||||
}).filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Variant 1: Apple iOS Segmented Control Style
|
||||
// Clean white/glass, segmented parameters, extremely rounded corners.
|
||||
// ============================================================================
|
||||
export function ModelDropdownV1(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
const summary = resolveParamSummary(props.capabilityFields, props.capabilityOverrides)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex items-center justify-between w-full h-[46px] px-4 rounded-[14px] transition-all duration-300 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-subtle)] hover:bg-[var(--glass-bg-surface-strong)] ${isOpen ? 'ring-2 ring-black/10 dark:ring-white/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="font-semibold text-[14px] text-[var(--glass-text-primary)]">
|
||||
{activeModel ? activeModel.label : props.placeholder}
|
||||
</span>
|
||||
{activeModel?.providerName && (
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-black/5 dark:bg-white/10 text-[var(--glass-text-secondary)]">
|
||||
{activeModel.providerName}
|
||||
</span>
|
||||
)}
|
||||
{summary && <span className="text-[12px] text-[var(--glass-text-tertiary)] ml-auto pr-2">{summary}</span>}
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
<div ref={panelRef} style={panelStyle} className="glass-surface-modal rounded-[20px] shadow-[0_12px_40px_-10px_rgba(0,0,0,0.15)] border border-[var(--glass-stroke-subtle)] overflow-hidden flex flex-col backdrop-blur-2xl bg-white/70 dark:bg-black/70">
|
||||
<div className="overflow-y-auto px-2 py-2 max-h-[220px]">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => props.onModelChange(m.value)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-[12px] mb-0.5 transition-all ${active ? 'bg-black text-white dark:bg-white dark:text-black shadow-md' : 'hover:bg-black/5 dark:hover:bg-white/10 text-[var(--glass-text-secondary)]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[14px] ${active ? 'font-bold' : 'font-medium'}`}>{m.label}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-md ${active ? 'bg-white/20 text-white dark:bg-black/10 dark:text-black' : 'border border-[var(--glass-stroke-base)] text-[var(--glass-text-tertiary)]'}`}>
|
||||
{m.providerName}
|
||||
</span>
|
||||
</div>
|
||||
{active && <AppIcon name="check" className="w-4 h-4 ml-2" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{props.capabilityFields.length > 0 && (
|
||||
<div className="bg-black/5 dark:bg-white/5 border-t border-[var(--glass-stroke-subtle)] p-3 space-y-3">
|
||||
{props.capabilityFields.map(field => {
|
||||
const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')
|
||||
return (
|
||||
<div key={field.field} className="flex items-center justify-between">
|
||||
<span className="text-[12px] font-semibold text-[var(--glass-text-secondary)]">{field.label || field.field}</span>
|
||||
<div className="flex bg-black/5 dark:bg-white/10 p-0.5 rounded-[10px]">
|
||||
{field.options.map((opt) => {
|
||||
const s = String(opt)
|
||||
const active = s === val
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}
|
||||
className={`px-3 py-1 text-[12px] font-medium rounded-[8px] transition-all ${active ? 'bg-white dark:bg-[#333] shadow-sm text-[var(--glass-text-primary)]' : 'text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Variant 2: Minimalist Tech Borderless (Vercel Style)
|
||||
// Sharp, thin borders, hover states very subtle, focus on typography
|
||||
// ============================================================================
|
||||
export function ModelDropdownV2(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
const summary = resolveParamSummary(props.capabilityFields, props.capabilityOverrides)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex items-center justify-between w-full h-[40px] px-3 rounded-md transition-colors bg-[var(--glass-bg-surface)] border ${isOpen ? 'border-[var(--glass-text-primary)]' : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-text-secondary)]'}`}
|
||||
>
|
||||
<div className="flex items-baseline gap-2 truncate">
|
||||
<span className="font-medium text-[13px] text-[var(--glass-text-primary)]">
|
||||
{activeModel ? activeModel.label : props.placeholder}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--glass-text-tertiary)]">
|
||||
{summary ? `— ${summary}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-3.5 h-3.5 text-[var(--glass-text-secondary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
<div ref={panelRef} style={panelStyle} className="glass-surface-modal rounded-md shadow-[0_4px_16px_rgba(0,0,0,0.1)] border border-[var(--glass-stroke-base)] overflow-hidden flex flex-col bg-[var(--glass-bg-surface-strong)]">
|
||||
<div className="overflow-y-auto max-h-[200px]">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => props.onModelChange(m.value)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-left group ${active ? 'bg-[var(--glass-bg-active)]' : 'hover:bg-[var(--glass-bg-hover)]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[13px] ${active ? 'text-[var(--glass-text-primary)] font-medium' : 'text-[var(--glass-text-secondary)]'}`}>{m.label}</span>
|
||||
{m.providerName && (
|
||||
<span className="text-[10px] text-[var(--glass-text-tertiary)]">{m.providerName}</span>
|
||||
)}
|
||||
</div>
|
||||
{active && <AppIcon name="check" className="w-3.5 h-3.5 text-[var(--glass-text-primary)]" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{props.capabilityFields.length > 0 && (
|
||||
<div className="border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-base)]">
|
||||
{props.capabilityFields.map(field => {
|
||||
const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')
|
||||
return (
|
||||
<div key={field.field} className="flex flex-col border-b last:border-0 border-[var(--glass-stroke-subtle)]">
|
||||
<div className="px-3 pt-2 text-[10px] tracking-wider uppercase text-[var(--glass-text-tertiary)] font-semibold">{field.label || field.field}</div>
|
||||
<div className="flex px-2 pb-2 mt-1 flex-wrap gap-1">
|
||||
{field.options.map((opt) => {
|
||||
const s = String(opt)
|
||||
const active = s === val
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}
|
||||
className={`min-w-[40px] px-2 py-1 text-[11px] font-mono rounded transition-colors ${active ? 'bg-[var(--glass-text-primary)] text-[var(--glass-bg-base)]' : 'bg-transparent text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-hover)] box-border border border-transparent'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Variant 3: Neon / Playful Flow (Gradient accents, expressive)
|
||||
// Premium AI feeling with slight accent colors and generous padding
|
||||
// ============================================================================
|
||||
export function ModelDropdownV3(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
const summary = resolveParamSummary(props.capabilityFields, props.capabilityOverrides)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`relative flex items-center justify-between w-full h-[54px] px-4 rounded-[16px] transition-all duration-300 overflow-hidden group bg-[var(--glass-bg-surface)] backdrop-blur-xl ${isOpen ? 'shadow-[0_0_0_2px_#3B82F6]' : 'hover:shadow-[0_4px_24px_rgba(0,0,0,0.05)] border border-[var(--glass-stroke-base)]'}`}
|
||||
>
|
||||
{isOpen && <div className="absolute inset-0 bg-blue-500/5 transition-opacity duration-300" />}
|
||||
<div className="relative flex flex-col items-start min-w-0 pr-4 z-10">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span className="font-bold text-[15px] truncate text-[var(--glass-text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||
{activeModel ? activeModel.label : props.placeholder}
|
||||
</span>
|
||||
{activeModel?.providerName && (
|
||||
<span className="px-1.5 py-0.5 rounded border border-[var(--glass-stroke-base)] text-[10px] text-[var(--glass-text-tertiary)] uppercase tracking-wider">
|
||||
{activeModel.providerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{summary && <span className="text-[12px] mt-0.5 font-medium text-blue-500 dark:text-blue-400 opacity-80">{summary}</span>}
|
||||
</div>
|
||||
<div className="relative w-8 h-8 rounded-full bg-[var(--glass-bg-muted)] flex items-center justify-center shrink-0 group-hover:bg-[var(--glass-bg-surface-strong)] transition-colors z-10">
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-secondary)] transition-transform duration-300 ${isOpen ? 'rotate-180 text-blue-500' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
<div ref={panelRef} style={panelStyle} className="glass-surface-modal rounded-[20px] shadow-[0_24px_48px_rgba(0,0,0,0.2)] border border-[var(--glass-stroke-base)] flex flex-col bg-gradient-to-br from-[var(--glass-bg-surface-strong)] to-[var(--glass-bg-surface)] backdrop-blur-3xl overflow-hidden">
|
||||
<div className="overflow-y-auto max-h-[220px] p-2 space-y-1">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => props.onModelChange(m.value)}
|
||||
className={`w-full flex items-center px-4 py-3 rounded-xl transition-all ${active ? 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white shadow-lg' : 'hover:bg-[var(--glass-bg-hover)] text-[var(--glass-text-secondary)]'}`}
|
||||
>
|
||||
<div className="w-5 h-5 mr-3 shrink-0 flex items-center justify-center">
|
||||
{active ? <AppIcon name="check" className="w-4 h-4 text-white" /> : <div className="w-1.5 h-1.5 rounded-full bg-[var(--glass-text-tertiary)] opacity-30" />}
|
||||
</div>
|
||||
<span className={`text-[14px] flex-1 text-left ${active ? 'font-bold' : 'font-medium'}`}>{m.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{props.capabilityFields.length > 0 && (
|
||||
<div className="p-4 border-t border-[var(--glass-stroke-subtle)] bg-black/5 dark:bg-white/5 space-y-4">
|
||||
{props.capabilityFields.map(field => {
|
||||
const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')
|
||||
return (
|
||||
<div key={field.field} className="flex flex-col gap-2">
|
||||
<div className="text-[13px] font-semibold text-[var(--glass-text-primary)]">{field.label || field.field}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{field.options.map((opt) => {
|
||||
const s = String(opt)
|
||||
const active = s === val
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}
|
||||
className={`px-4 py-1.5 rounded-full text-[12px] font-bold transition-all border ${active ? 'bg-blue-500/10 border-blue-500 text-blue-600 dark:text-blue-400' : 'bg-transparent border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] hover:border-[var(--glass-text-tertiary)]'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Variant 4: Card Overlay (Like the original photo, but beautifully refined)
|
||||
// Dual-tone top and bottom, very clear visual hierarchy.
|
||||
// ============================================================================
|
||||
export function ModelDropdownV4(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
|
||||
// Convert to what original had: e.g. "2 · 720p"
|
||||
const summary = resolveParamSummary(props.capabilityFields, props.capabilityOverrides)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex items-center justify-between w-full h-[50px] px-4 rounded-[12px] bg-[var(--glass-bg-surface)] border transition-shadow duration-200 ${isOpen ? 'border-[#8B5CF6] shadow-[0_0_0_4px_rgba(139,92,246,0.15)] ring-0' : 'border-[var(--glass-stroke-base)] hover:border-gray-400 dark:hover:border-gray-500'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-[15px] text-[var(--glass-text-primary)]">{activeModel ? activeModel.label : props.placeholder}</span>
|
||||
{activeModel?.providerName && (
|
||||
<span className="px-2 py-0.5 rounded-[6px] border border-[var(--glass-stroke-subtle)] text-[11px] text-[var(--glass-text-secondary)] bg-[var(--glass-bg-base)] shadow-sm">
|
||||
{activeModel.providerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{summary && <span className="font-medium text-[13px] text-[var(--glass-text-secondary)]">{summary}</span>}
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
<div ref={panelRef} style={panelStyle} className="glass-surface-modal rounded-[16px] shadow-[0_16px_50px_rgba(0,0,0,0.12)] border border-[var(--glass-stroke-base)] overflow-hidden flex flex-col bg-white dark:bg-[#1C1C1E]">
|
||||
{/* Top: Models */}
|
||||
<div className="px-3 pt-3 pb-2 bg-[var(--glass-bg-base)]">
|
||||
<div className="text-[12px] font-bold text-[var(--glass-text-secondary)] mb-2 px-1">选择模型</div>
|
||||
<div className="overflow-y-auto max-h-[160px] custom-scrollbar space-y-1 pr-1">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => props.onModelChange(m.value)}
|
||||
className={`w-full flex items-center px-3 py-2.5 rounded-[10px] transition-all border ${active ? 'bg-blue-50/50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800' : 'bg-transparent border-transparent hover:bg-black/5 dark:hover:bg-white/5'}`}
|
||||
>
|
||||
<span className={`text-[14px] flex-1 text-left ${active ? 'font-semibold text-blue-600 dark:text-blue-400' : 'text-[var(--glass-text-primary)] font-medium'}`}>{m.label}</span>
|
||||
{m.providerName && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] bg-black/5 dark:bg-white/10 text-[var(--glass-text-secondary)] ml-2">{m.providerName}</span>
|
||||
)}
|
||||
{active && <div className="w-1.5 h-6 rounded-full bg-blue-500 ml-3" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom: Settings */}
|
||||
{props.capabilityFields.length > 0 && (
|
||||
<div className="p-4 bg-[var(--glass-bg-surface)] border-t border-[var(--glass-stroke-subtle)] space-y-4">
|
||||
<div className="text-[12px] font-bold text-[var(--glass-text-secondary)]">参数配置</div>
|
||||
{props.capabilityFields.map(field => {
|
||||
const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')
|
||||
|
||||
// To mimic the "duration using select, ratio using pill" behaviour from the original
|
||||
const useSelectBox = field.options.every(o => typeof o === 'number') || field.field.toLowerCase().includes('duration')
|
||||
|
||||
return (
|
||||
<div key={field.field} className="flex items-center justify-between gap-4">
|
||||
<span className="text-[14px] text-[var(--glass-text-primary)] font-medium shrink-0">{field.label || field.field}</span>
|
||||
|
||||
{useSelectBox ? (
|
||||
<div className="relative w-[120px]">
|
||||
<select
|
||||
value={val}
|
||||
onChange={e => props.onCapabilityChange(field.field, e.target.value, field.options[0])}
|
||||
className="w-full h-[36px] appearance-none bg-[var(--glass-bg-base)] border border-[var(--glass-stroke-base)] rounded-[8px] px-3 font-medium text-[13px] text-[var(--glass-text-primary)] focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{field.options.map(opt => <option key={String(opt)} value={String(opt)}>{opt}</option>)}
|
||||
</select>
|
||||
<AppIcon name="chevronDown" className="w-4 h-4 text-[var(--glass-text-tertiary)] absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex bg-[var(--glass-bg-base)] p-[3px] rounded-[10px] border border-[var(--glass-stroke-subtle)]">
|
||||
{field.options.map((opt) => {
|
||||
const s = String(opt)
|
||||
const active = s === val
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}
|
||||
className={`px-3 py-1.5 text-[13px] font-medium rounded-[7px] transition-all min-w-[50px] text-center ${active ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 shadow-sm' : 'text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] hover:bg-black/5 dark:hover:bg-white/5'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Variant 5: Ultra-Minimal Inline Flat
|
||||
// No explicit boxes for the dropdown fields. A seamless, document-like feel.
|
||||
// ============================================================================
|
||||
export function ModelDropdownV5(props: ModelDropdownTestProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)
|
||||
const activeModel = props.models.find(m => m.value === props.value)
|
||||
const summary = resolveParamSummary(props.capabilityFields, props.capabilityOverrides)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`group flex flex-col justify-center w-full px-2 py-2 rounded-lg transition-all border-b-2 ${isOpen ? 'border-[#FCA5A5] bg-[var(--glass-bg-hover)]' : 'border-transparent hover:border-[var(--glass-stroke-base)] hover:bg-[var(--glass-bg-surface)]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span className="font-semibold text-[16px] text-[var(--glass-text-primary)]">
|
||||
{activeModel ? activeModel.label : props.placeholder}
|
||||
</span>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] ml-auto transition-transform ${isOpen ? 'rotate-180 text-[#FCA5A5]' : 'group-hover:text-[var(--glass-text-primary)]'}`} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 opacity-70">
|
||||
<span className="text-[12px] text-[var(--glass-text-tertiary)] font-mono uppercase">
|
||||
{activeModel?.providerName || 'MODEL'}
|
||||
</span>
|
||||
{summary && (
|
||||
<>
|
||||
<span className="w-1 h-1 rounded-full bg-gray-400" />
|
||||
<span className="text-[12px] text-[var(--glass-text-secondary)]">{summary}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
<div ref={panelRef} style={panelStyle} className="glass-surface-modal rounded-xl shadow-[0_20px_60px_-15px_rgba(0,0,0,0.3)] border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-base)] overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto max-h-[200px] p-2">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => props.onModelChange(m.value)}
|
||||
className={`relative flex items-center w-full px-4 py-3 rounded-lg text-left transition-colors mb-1 overflow-hidden ${active ? 'bg-[#FCA5A5]/10' : 'hover:bg-[var(--glass-bg-surface-strong)]'}`}
|
||||
>
|
||||
{active && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-1/2 bg-[#FCA5A5] rounded-r-md" />}
|
||||
<span className={`text-[15px] flex-1 ${active ? 'text-[#FCA5A5] font-bold' : 'text-[var(--glass-text-primary)] font-medium'}`}>{m.label}</span>
|
||||
{m.providerName && (
|
||||
<span className="text-[11px] font-mono text-[var(--glass-text-tertiary)] bg-[var(--glass-bg-surface)] px-2 py-0.5 rounded">{m.providerName}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{props.capabilityFields.length > 0 && (
|
||||
<div className="p-4 bg-[var(--glass-bg-surface-strong)] border-t border-[var(--glass-stroke-base)] space-y-4">
|
||||
{props.capabilityFields.map(field => {
|
||||
const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')
|
||||
return (
|
||||
<div key={field.field} className="flex flex-col gap-2">
|
||||
<span className="text-[11px] uppercase tracking-widest font-bold text-[var(--glass-text-tertiary)]">{field.label || field.field}</span>
|
||||
<div className="flex gap-2">
|
||||
{field.options.map((opt) => {
|
||||
const s = String(opt)
|
||||
const active = s === val
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}
|
||||
className={`flex-1 py-1.5 text-[13px] font-semibold rounded-md border-b-2 transition-all ${active ? 'border-[#FCA5A5] text-[#FCA5A5] bg-[#FCA5A5]/5' : 'border-transparent text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-hover)]'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
183
src/components/ui/patterns/PanelCardV2.tsx
Normal file
183
src/components/ui/patterns/PanelCardV2.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { PanelEditData } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/PanelEditForm'
|
||||
import type { StoryboardPanel } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardState'
|
||||
import { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'
|
||||
import { GlassButton, GlassChip, GlassSurface } from '@/components/ui/primitives'
|
||||
import PanelEditFormV2 from './PanelEditFormV2'
|
||||
import type { UiPatternMode } from './types'
|
||||
|
||||
interface PanelCandidateData {
|
||||
candidates: string[]
|
||||
selectedIndex: number
|
||||
}
|
||||
|
||||
export interface PanelCardV2Props {
|
||||
panel: StoryboardPanel
|
||||
panelData: PanelEditData
|
||||
imageUrl: string | null
|
||||
globalPanelNumber: number
|
||||
isSaving: boolean
|
||||
isDeleting: boolean
|
||||
isModifying: boolean
|
||||
isTaskRunning: boolean
|
||||
failedError: string | null
|
||||
candidateData: PanelCandidateData | null
|
||||
onUpdate: (updates: Partial<PanelEditData>) => void
|
||||
onDelete: () => void
|
||||
onOpenCharacterPicker: () => void
|
||||
onOpenLocationPicker: () => void
|
||||
onRemoveCharacter: (index: number) => void
|
||||
onRemoveLocation: () => void
|
||||
onRegeneratePanelImage: (panelId: string, count?: number, force?: boolean) => void
|
||||
onOpenEditModal: () => void
|
||||
onOpenAIDataModal: () => void
|
||||
onSelectCandidateIndex: (panelId: string, index: number) => void
|
||||
onConfirmCandidate: (panelId: string, imageUrl: string) => Promise<void>
|
||||
onCancelCandidate: (panelId: string) => void
|
||||
onClearError: () => void
|
||||
uiMode?: UiPatternMode
|
||||
}
|
||||
|
||||
export default function PanelCardV2({
|
||||
panel,
|
||||
panelData,
|
||||
imageUrl,
|
||||
globalPanelNumber,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
isModifying,
|
||||
isTaskRunning,
|
||||
failedError,
|
||||
candidateData,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onOpenCharacterPicker,
|
||||
onOpenLocationPicker,
|
||||
onRemoveCharacter,
|
||||
onRemoveLocation,
|
||||
onRegeneratePanelImage,
|
||||
onOpenEditModal,
|
||||
onOpenAIDataModal,
|
||||
onSelectCandidateIndex,
|
||||
onConfirmCandidate,
|
||||
onCancelCandidate,
|
||||
onClearError,
|
||||
uiMode = 'flow'
|
||||
}: PanelCardV2Props) {
|
||||
const t = useTranslations('storyboard')
|
||||
const selectedCandidate =
|
||||
candidateData && candidateData.candidates[candidateData.selectedIndex]
|
||||
? candidateData.candidates[candidateData.selectedIndex]
|
||||
: null
|
||||
|
||||
return (
|
||||
<GlassSurface
|
||||
variant="elevated"
|
||||
padded={false}
|
||||
className={`ui-pattern-panel-card ui-pattern-panel-card-${uiMode} relative overflow-hidden`}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="aspect-[9/16] w-full overflow-hidden bg-[rgba(255,255,255,0.35)]">
|
||||
{isDeleting || isModifying || isTaskRunning ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<GlassChip tone={isDeleting ? 'danger' : 'info'}>
|
||||
{isDeleting
|
||||
? t('common.deleting')
|
||||
: isModifying
|
||||
? t('common.editing')
|
||||
: t('image.generating')}
|
||||
</GlassChip>
|
||||
</div>
|
||||
) : failedError ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<GlassChip tone="danger">{t('image.failed')}</GlassChip>
|
||||
<p className="text-xs text-[var(--glass-text-secondary)]">{failedError}</p>
|
||||
<GlassButton size="sm" variant="ghost" onClick={onClearError}>{t('common.cancel')}</GlassButton>
|
||||
</div>
|
||||
) : selectedCandidate ? (
|
||||
<MediaImageWithLoading
|
||||
src={selectedCandidate}
|
||||
alt="candidate"
|
||||
containerClassName="h-full w-full"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : imageUrl ? (
|
||||
<MediaImageWithLoading
|
||||
src={imageUrl}
|
||||
alt="panel"
|
||||
containerClassName="h-full w-full"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<GlassButton size="sm" variant="secondary" onClick={() => onRegeneratePanelImage(panel.id, 1)}>
|
||||
{t('panel.generateImage')}
|
||||
</GlassButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute left-2 top-2 flex items-center gap-2">
|
||||
<GlassChip tone="neutral">#{globalPanelNumber}</GlassChip>
|
||||
<GlassChip tone="info">{panel.shot_type || t('panel.noShotType')}</GlassChip>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-2 top-2">
|
||||
<GlassButton size="sm" variant="danger" onClick={onDelete}>{t('common.delete')}</GlassButton>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-2 left-2 right-2 flex flex-wrap items-center gap-2">
|
||||
<GlassButton size="sm" variant="secondary" onClick={() => onRegeneratePanelImage(panel.id, 1, isTaskRunning)}>
|
||||
{t('image.regenerate')}
|
||||
</GlassButton>
|
||||
<GlassButton size="sm" variant="secondary" onClick={onOpenEditModal}>{t('image.editImage')}</GlassButton>
|
||||
<GlassButton size="sm" variant="secondary" onClick={onOpenAIDataModal}>{t('aiData.title')}</GlassButton>
|
||||
|
||||
{candidateData ? (
|
||||
<>
|
||||
<GlassButton size="sm" variant="ghost" onClick={() => onCancelCandidate(panel.id)}>{t('image.cancelSelection')}</GlassButton>
|
||||
<GlassButton
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const candidate = candidateData.candidates[candidateData.selectedIndex]
|
||||
if (candidate) {
|
||||
void onConfirmCandidate(panel.id, candidate)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('image.confirmCandidate')}
|
||||
</GlassButton>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{candidateData.candidates.slice(0, 4).map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => onSelectCandidateIndex(panel.id, index)}
|
||||
className={`h-2.5 w-2.5 rounded-full ${index === candidateData.selectedIndex ? 'bg-[var(--glass-accent-from)]' : 'bg-[var(--glass-bg-surface)]/80 border border-[var(--glass-stroke-base)]'}`}
|
||||
aria-label={`candidate-${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<PanelEditFormV2
|
||||
panelData={panelData}
|
||||
isSaving={isSaving}
|
||||
onUpdate={onUpdate}
|
||||
onOpenCharacterPicker={onOpenCharacterPicker}
|
||||
onOpenLocationPicker={onOpenLocationPicker}
|
||||
onRemoveCharacter={onRemoveCharacter}
|
||||
onRemoveLocation={onRemoveLocation}
|
||||
uiMode={uiMode}
|
||||
/>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
)
|
||||
}
|
||||
168
src/components/ui/patterns/PanelEditFormV2.tsx
Normal file
168
src/components/ui/patterns/PanelEditFormV2.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { PanelEditData } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/PanelEditForm'
|
||||
import {
|
||||
GlassChip,
|
||||
GlassField,
|
||||
GlassInput,
|
||||
GlassTextarea
|
||||
} from '@/components/ui/primitives'
|
||||
import type { UiPatternMode } from './types'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
export interface PanelEditFormV2Props {
|
||||
panelData: PanelEditData
|
||||
isSaving?: boolean
|
||||
saveStatus?: 'idle' | 'saving' | 'error'
|
||||
saveErrorMessage?: string | null
|
||||
onRetrySave?: () => void
|
||||
onUpdate: (updates: Partial<PanelEditData>) => void
|
||||
onOpenCharacterPicker: () => void
|
||||
onOpenLocationPicker: () => void
|
||||
onRemoveCharacter: (index: number) => void
|
||||
onRemoveLocation: () => void
|
||||
uiMode?: UiPatternMode
|
||||
}
|
||||
|
||||
export default function PanelEditFormV2({
|
||||
panelData,
|
||||
isSaving = false,
|
||||
saveStatus = 'idle',
|
||||
saveErrorMessage = null,
|
||||
onRetrySave,
|
||||
onUpdate,
|
||||
onOpenCharacterPicker,
|
||||
onOpenLocationPicker,
|
||||
onRemoveCharacter,
|
||||
onRemoveLocation,
|
||||
uiMode = 'flow'
|
||||
}: PanelEditFormV2Props) {
|
||||
const t = useTranslations('storyboard')
|
||||
|
||||
return (
|
||||
<div className={`ui-pattern-form ui-pattern-form-${uiMode} space-y-2`}>
|
||||
{saveStatus === 'saving' || isSaving ? (
|
||||
<GlassChip tone="info" icon={<span className="h-2 w-2 animate-pulse rounded-full bg-current" />}>
|
||||
{t('common.saving')}
|
||||
</GlassChip>
|
||||
) : null}
|
||||
{saveStatus === 'error' ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<GlassChip tone="danger">
|
||||
{saveErrorMessage || t('common.saveFailed')}
|
||||
</GlassChip>
|
||||
{onRetrySave ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetrySave}
|
||||
className="glass-btn-base glass-btn-soft px-2 py-1 text-xs"
|
||||
>
|
||||
{t('common.retrySave')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<GlassField label={t('panel.shotTypeLabel')}>
|
||||
<GlassInput
|
||||
density="compact"
|
||||
value={panelData.shotType || ''}
|
||||
onChange={(event) => onUpdate({ shotType: event.target.value || null })}
|
||||
placeholder={t('panel.shotTypePlaceholder')}
|
||||
/>
|
||||
</GlassField>
|
||||
|
||||
<GlassField label={t('panel.cameraMove')}>
|
||||
<GlassInput
|
||||
density="compact"
|
||||
value={panelData.cameraMove || ''}
|
||||
onChange={(event) => onUpdate({ cameraMove: event.target.value || null })}
|
||||
placeholder={t('panel.cameraMovePlaceholder')}
|
||||
/>
|
||||
</GlassField>
|
||||
</div>
|
||||
|
||||
{panelData.sourceText ? (
|
||||
<GlassField label={t('panel.sourceText')}>
|
||||
<div className="rounded-[var(--glass-radius-md)] bg-[var(--glass-bg-surface-strong)] px-3 py-2.5">
|
||||
<p className="text-sm leading-6 text-[var(--glass-text-secondary)]">“{panelData.sourceText}”</p>
|
||||
</div>
|
||||
</GlassField>
|
||||
) : null}
|
||||
|
||||
<GlassField label={t('panel.sceneDescription')}>
|
||||
<GlassTextarea
|
||||
density="compact"
|
||||
rows={2}
|
||||
value={panelData.description || ''}
|
||||
onChange={(event) => onUpdate({ description: event.target.value })}
|
||||
placeholder={t('panel.sceneDescriptionPlaceholder')}
|
||||
/>
|
||||
</GlassField>
|
||||
|
||||
<GlassField label={t('panel.videoPrompt')} hint={t('panel.videoPromptHint')}>
|
||||
<GlassTextarea
|
||||
density="compact"
|
||||
rows={2}
|
||||
value={panelData.videoPrompt || ''}
|
||||
onChange={(event) => onUpdate({ videoPrompt: event.target.value })}
|
||||
placeholder={t('panel.videoPromptPlaceholder')}
|
||||
/>
|
||||
</GlassField>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 xl:grid-cols-2">
|
||||
<GlassField
|
||||
label={t('panel.locationLabel')}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenLocationPicker}
|
||||
className="inline-flex h-8 w-8 items-center justify-center text-[var(--glass-text-secondary)] hover:text-[var(--glass-tone-info-fg)] transition-colors"
|
||||
aria-label={t('panel.editLocation')}
|
||||
title={t('panel.editLocation')}
|
||||
>
|
||||
<AppIcon name="edit" className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{panelData.location ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<GlassChip tone="success" onRemove={onRemoveLocation}>{panelData.location}</GlassChip>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)]">{t('panel.locationNotEdited')}</p>
|
||||
)}
|
||||
</GlassField>
|
||||
|
||||
<GlassField
|
||||
label={t('panel.characterLabelWithCount', { count: panelData.characters.length })}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCharacterPicker}
|
||||
className="inline-flex h-8 w-8 items-center justify-center text-[var(--glass-text-secondary)] hover:text-[var(--glass-tone-info-fg)] transition-colors"
|
||||
aria-label={t('panel.editCharacter')}
|
||||
title={t('panel.editCharacter')}
|
||||
>
|
||||
<AppIcon name="edit" className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{panelData.characters.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{panelData.characters.map((character, index) => (
|
||||
<GlassChip key={`${character.name}-${index}`} tone="info" onRemove={() => onRemoveCharacter(index)}>
|
||||
{character.name}({character.appearance})
|
||||
</GlassChip>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)]">{t('panel.charactersNotEdited')}</p>
|
||||
)}
|
||||
</GlassField>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
src/components/ui/patterns/StoryboardHeaderV2.tsx
Normal file
79
src/components/ui/patterns/StoryboardHeaderV2.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { GlassButton, GlassChip, GlassSurface } from '@/components/ui/primitives'
|
||||
import type { UiPatternMode } from './types'
|
||||
|
||||
export interface StoryboardHeaderV2Props {
|
||||
totalSegments: number
|
||||
totalPanels: number
|
||||
isDownloadingImages: boolean
|
||||
runningCount: number
|
||||
pendingPanelCount: number
|
||||
isBatchSubmitting: boolean
|
||||
onDownloadAllImages: () => void
|
||||
onGenerateAllPanels: () => void
|
||||
onBack: () => void
|
||||
uiMode?: UiPatternMode
|
||||
}
|
||||
|
||||
export default function StoryboardHeaderV2({
|
||||
totalSegments,
|
||||
totalPanels,
|
||||
isDownloadingImages,
|
||||
runningCount,
|
||||
pendingPanelCount,
|
||||
isBatchSubmitting,
|
||||
onDownloadAllImages,
|
||||
onGenerateAllPanels,
|
||||
onBack,
|
||||
uiMode = 'flow'
|
||||
}: StoryboardHeaderV2Props) {
|
||||
const t = useTranslations('storyboard')
|
||||
|
||||
return (
|
||||
<GlassSurface variant="elevated" className={`ui-pattern-header ui-pattern-header-${uiMode} space-y-4`}>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-[var(--glass-text-primary)]">{t('header.storyboardPanel')} (V2)</h3>
|
||||
<p className="text-sm text-[var(--glass-text-secondary)]">
|
||||
{t('header.segmentsCount', { count: totalSegments })} {t('header.panelsCount', { count: totalPanels })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{runningCount > 0 ? (
|
||||
<GlassChip tone="info" icon={<span className="h-2 w-2 animate-pulse rounded-full bg-current" />}>
|
||||
{t('header.generatingStatus', { count: runningCount })}
|
||||
</GlassChip>
|
||||
) : null}
|
||||
<GlassChip tone="neutral">{t('header.concurrencyLimit', { count: 10 })}</GlassChip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{pendingPanelCount > 0 ? (
|
||||
<GlassButton
|
||||
variant="primary"
|
||||
loading={isBatchSubmitting}
|
||||
onClick={onGenerateAllPanels}
|
||||
disabled={runningCount > 0}
|
||||
>
|
||||
{t('header.generatePendingPanels', { count: pendingPanelCount })}
|
||||
</GlassButton>
|
||||
) : null}
|
||||
|
||||
<GlassButton
|
||||
variant="secondary"
|
||||
loading={isDownloadingImages}
|
||||
onClick={onDownloadAllImages}
|
||||
disabled={totalPanels === 0}
|
||||
>
|
||||
{t('header.downloadAll')}
|
||||
</GlassButton>
|
||||
|
||||
<GlassButton variant="ghost" onClick={onBack}>{t('header.back')}</GlassButton>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
)
|
||||
}
|
||||
10
src/components/ui/patterns/index.ts
Normal file
10
src/components/ui/patterns/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as StoryboardHeaderV2 } from './StoryboardHeaderV2'
|
||||
export type { StoryboardHeaderV2Props } from './StoryboardHeaderV2'
|
||||
|
||||
export { default as PanelEditFormV2 } from './PanelEditFormV2'
|
||||
export type { PanelEditFormV2Props } from './PanelEditFormV2'
|
||||
|
||||
export { default as PanelCardV2 } from './PanelCardV2'
|
||||
export type { PanelCardV2Props } from './PanelCardV2'
|
||||
|
||||
export type { UiPatternMode } from './types'
|
||||
1
src/components/ui/patterns/types.ts
Normal file
1
src/components/ui/patterns/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type UiPatternMode = 'flow'
|
||||
66
src/components/ui/primitives/GlassButton.tsx
Normal file
66
src/components/ui/primitives/GlassButton.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
|
||||
export interface GlassButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
loading?: boolean
|
||||
iconLeft?: ReactNode
|
||||
iconRight?: ReactNode
|
||||
}
|
||||
|
||||
function cx(...names: Array<string | false | null | undefined>) {
|
||||
return names.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const GlassButton = forwardRef<HTMLButtonElement, GlassButtonProps>(function GlassButton(
|
||||
{
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
iconLeft,
|
||||
iconRight,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const variantClass =
|
||||
variant === 'primary' ? 'glass-btn-primary' :
|
||||
variant === 'ghost' ? 'glass-btn-ghost' :
|
||||
variant === 'danger' ? 'glass-btn-danger' :
|
||||
'glass-btn-secondary'
|
||||
|
||||
const sizeClass =
|
||||
size === 'sm' ? 'h-8 px-3 text-xs' :
|
||||
size === 'lg' ? 'h-11 px-5 text-base' :
|
||||
'h-9 px-4 text-sm'
|
||||
const loadingState = loading
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'generate',
|
||||
resource: 'text',
|
||||
hasOutput: true,
|
||||
})
|
||||
: null
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cx('glass-btn-base', variantClass, sizeClass, className)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<TaskStatusInline state={loadingState} className="[&>span]:sr-only" />
|
||||
) : iconLeft}
|
||||
{children}
|
||||
{!loading && iconRight}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export default GlassButton
|
||||
42
src/components/ui/primitives/GlassChip.tsx
Normal file
42
src/components/ui/primitives/GlassChip.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
export type UiTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
export interface GlassChipProps {
|
||||
tone?: UiTone
|
||||
icon?: ReactNode
|
||||
onRemove?: () => void
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function cx(...names: Array<string | false | null | undefined>) {
|
||||
return names.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function GlassChip({ tone = 'neutral', icon, onRemove, children, className }: GlassChipProps) {
|
||||
const toneClass =
|
||||
tone === 'info' ? 'glass-chip-info' :
|
||||
tone === 'success' ? 'glass-chip-success' :
|
||||
tone === 'warning' ? 'glass-chip-warning' :
|
||||
tone === 'danger' ? 'glass-chip-danger' :
|
||||
'glass-chip-neutral'
|
||||
|
||||
return (
|
||||
<span className={cx('glass-chip', toneClass, className)}>
|
||||
{icon}
|
||||
<span>{children}</span>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="rounded-full p-0.5 transition-colors hover:bg-black/10"
|
||||
aria-label="remove"
|
||||
>
|
||||
<AppIcon name="close" className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
49
src/components/ui/primitives/GlassField.tsx
Normal file
49
src/components/ui/primitives/GlassField.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export interface GlassFieldProps {
|
||||
id?: string
|
||||
label?: ReactNode
|
||||
hint?: ReactNode
|
||||
error?: ReactNode
|
||||
required?: boolean
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function cx(...names: Array<string | false | null | undefined>) {
|
||||
return names.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function GlassField({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required = false,
|
||||
actions,
|
||||
className,
|
||||
children
|
||||
}: GlassFieldProps) {
|
||||
return (
|
||||
<div className={cx('space-y-1.5', className)}>
|
||||
{(label || actions) && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{label ? (
|
||||
<label htmlFor={id} className="glass-field-label">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-[var(--glass-tone-danger-fg)]">*</span> : null}
|
||||
</label>
|
||||
) : <span />}
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--glass-tone-danger-fg)]">{error}</p>
|
||||
) : hint ? (
|
||||
<p className="glass-field-hint">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
src/components/ui/primitives/GlassInput.tsx
Normal file
28
src/components/ui/primitives/GlassInput.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react'
|
||||
|
||||
export interface GlassInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
density?: 'compact' | 'default'
|
||||
}
|
||||
|
||||
function cx(...names: Array<string | false | null | undefined>) {
|
||||
return names.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const GlassInput = forwardRef<HTMLInputElement, GlassInputProps>(function GlassInput(
|
||||
{ density = 'default', className, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'glass-input-base',
|
||||
density === 'compact' ? 'h-9 px-3 text-sm leading-5' : 'h-10 px-3 text-sm leading-5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export default GlassInput
|
||||
101
src/components/ui/primitives/GlassModalShell.tsx
Normal file
101
src/components/ui/primitives/GlassModalShell.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
export interface GlassModalShellProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
footer?: ReactNode
|
||||
children: ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
closeOnBackdrop?: boolean
|
||||
closeOnEsc?: boolean
|
||||
showCloseButton?: boolean
|
||||
}
|
||||
|
||||
function cx(...names: Array<string | false | null | undefined>) {
|
||||
return names.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function GlassModalShell({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
children,
|
||||
size = 'md',
|
||||
closeOnBackdrop = true,
|
||||
closeOnEsc = true,
|
||||
showCloseButton = true
|
||||
}: GlassModalShellProps) {
|
||||
useEffect(() => {
|
||||
if (!open || !closeOnEsc) return
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
return () => window.removeEventListener('keydown', onKeydown)
|
||||
}, [open, closeOnEsc, onClose])
|
||||
|
||||
if (!open || typeof document === 'undefined') return null
|
||||
|
||||
const maxWidthClass =
|
||||
size === 'sm' ? 'max-w-md' :
|
||||
size === 'lg' ? 'max-w-4xl' :
|
||||
size === 'xl' ? 'max-w-6xl' :
|
||||
'max-w-2xl'
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[120] flex items-center justify-center p-4 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onMouseDown={(event) => {
|
||||
if (closeOnBackdrop && event.target === event.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="glass-overlay absolute inset-0"
|
||||
onMouseDown={() => {
|
||||
if (closeOnBackdrop) onClose()
|
||||
}}
|
||||
/>
|
||||
<div className={cx('glass-surface-modal relative z-10 w-full overflow-hidden', maxWidthClass)}>
|
||||
{(title || description || showCloseButton) && (
|
||||
<div className="flex items-start justify-between gap-4 px-5 py-4 sm:px-6">
|
||||
<div>
|
||||
{title ? <h2 className="text-lg font-semibold text-[var(--glass-text-primary)] sm:text-xl">{title}</h2> : null}
|
||||
{description ? <p className="mt-1 text-sm text-[var(--glass-text-secondary)]">{description}</p> : null}
|
||||
</div>
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-ghost h-9 w-9"
|
||||
aria-label="close"
|
||||
>
|
||||
<AppIcon name="close" className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="glass-divider" />
|
||||
<div className="px-5 py-4 sm:px-6 sm:py-5">{children}</div>
|
||||
|
||||
{footer ? (
|
||||
<>
|
||||
<div className="glass-divider" />
|
||||
<div className="px-5 py-4 sm:px-6">{footer}</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
46
src/components/ui/primitives/GlassSurface.tsx
Normal file
46
src/components/ui/primitives/GlassSurface.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type UiDensity = 'compact' | 'default'
|
||||
|
||||
export interface GlassSurfaceProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
variant?: 'panel' | 'card' | 'elevated' | 'modal'
|
||||
density?: UiDensity
|
||||
interactive?: boolean
|
||||
padded?: boolean
|
||||
}
|
||||
|
||||
function cx(...names: Array<string | false | null | undefined>) {
|
||||
return names.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function GlassSurface({
|
||||
children,
|
||||
className,
|
||||
variant = 'panel',
|
||||
density = 'default',
|
||||
interactive = false,
|
||||
padded = true
|
||||
}: GlassSurfaceProps) {
|
||||
const variantClass =
|
||||
variant === 'elevated' ? 'glass-surface-elevated' :
|
||||
variant === 'modal' ? 'glass-surface-modal' :
|
||||
'glass-surface'
|
||||
|
||||
const densityClass = density === 'compact' ? 'glass-density-compact' : 'glass-density-default'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
variantClass,
|
||||
densityClass,
|
||||
padded ? 'p-4 md:p-6' : '',
|
||||
interactive ? 'transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[var(--glass-shadow-md)]' : '',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
src/components/ui/primitives/GlassTextarea.tsx
Normal file
28
src/components/ui/primitives/GlassTextarea.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { forwardRef, type TextareaHTMLAttributes } from 'react'
|
||||
|
||||
export interface GlassTextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
density?: 'compact' | 'default'
|
||||
}
|
||||
|
||||
function cx(...names: Array<string | false | null | undefined>) {
|
||||
return names.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const GlassTextarea = forwardRef<HTMLTextAreaElement, GlassTextareaProps>(function GlassTextarea(
|
||||
{ density = 'default', className, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'glass-textarea-base resize-none',
|
||||
density === 'compact' ? 'px-3 py-2 text-sm leading-6' : 'px-3 py-2.5 text-sm leading-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export default GlassTextarea
|
||||
20
src/components/ui/primitives/index.ts
Normal file
20
src/components/ui/primitives/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export { default as GlassSurface } from './GlassSurface'
|
||||
export type { GlassSurfaceProps, UiDensity } from './GlassSurface'
|
||||
|
||||
export { default as GlassButton } from './GlassButton'
|
||||
export type { GlassButtonProps } from './GlassButton'
|
||||
|
||||
export { default as GlassField } from './GlassField'
|
||||
export type { GlassFieldProps } from './GlassField'
|
||||
|
||||
export { default as GlassInput } from './GlassInput'
|
||||
export type { GlassInputProps } from './GlassInput'
|
||||
|
||||
export { default as GlassTextarea } from './GlassTextarea'
|
||||
export type { GlassTextareaProps } from './GlassTextarea'
|
||||
|
||||
export { default as GlassChip } from './GlassChip'
|
||||
export type { GlassChipProps, UiTone } from './GlassChip'
|
||||
|
||||
export { default as GlassModalShell } from './GlassModalShell'
|
||||
export type { GlassModalShellProps } from './GlassModalShell'
|
||||
449
src/components/ui/select-variants.tsx
Normal file
449
src/components/ui/select-variants.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
// ─── Constants & Types ─────────────────────────────────────────
|
||||
|
||||
const VIEWPORT_EDGE_GAP = 8
|
||||
const DEFAULT_MAX_HEIGHT = 280
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface CustomSelectProps {
|
||||
options: SelectOption[]
|
||||
value?: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ─── Variant 1: Pill / Solid Card Style ─────────────────────────
|
||||
// (最贴近”默认模型配置“卡片的经典风格,四周有饱满的边框与微弱底色)
|
||||
|
||||
export function SelectVariantCard({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '请选择...',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: CustomSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP
|
||||
const spaceAbove = rect.top - VIEWPORT_EDGE_GAP
|
||||
|
||||
let openUpward = false
|
||||
let currentMaxHeight = DEFAULT_MAX_HEIGHT
|
||||
|
||||
if (spaceBelow < 200 && spaceAbove > spaceBelow) {
|
||||
openUpward = true
|
||||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)
|
||||
} else {
|
||||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)
|
||||
}
|
||||
|
||||
setPanelStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
maxHeight: currentMaxHeight,
|
||||
...(openUpward
|
||||
? { bottom: viewportHeight - rect.top + 4 }
|
||||
: { top: rect.bottom + 4 }),
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return
|
||||
updatePosition()
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
}
|
||||
}, [isOpen, updatePosition])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`glass-input-base w-full flex items-center justify-between px-3 py-2.5 transition-all text-left ${isOpen ? 'ring-1 ring-[var(--glass-stroke-active)]' : ''
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-[var(--glass-bg-hover)]'} ${className}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
{selectedOption ? (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-[var(--glass-text-primary)] truncate">
|
||||
{selectedOption.label}
|
||||
</span>
|
||||
{selectedOption.description && (
|
||||
<span className="text-[11px] text-[var(--glass-text-tertiary)] truncate mt-0.5">
|
||||
{selectedOption.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-[var(--glass-text-tertiary)]">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<AppIcon
|
||||
name="chevronDown"
|
||||
className={`w-4 h-4 text-[var(--glass-text-tertiary)] shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-xl border border-[var(--glass-stroke-base)] py-1"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="overflow-y-auto custom-scrollbar px-1 py-1 max-h-full">
|
||||
{options.map((opt) => {
|
||||
const isSelected = value === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={opt.disabled}
|
||||
onClick={() => {
|
||||
if (opt.disabled) return
|
||||
onChange(opt.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center w-full px-3 py-2 my-0.5 rounded-lg text-left transition-all ${isSelected
|
||||
? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
|
||||
: opt.disabled
|
||||
? 'text-[var(--glass-text-tertiary)] opacity-60 cursor-not-allowed'
|
||||
: 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm ${isSelected ? 'font-semibold' : 'font-medium'}`}>
|
||||
{opt.label}
|
||||
</div>
|
||||
{opt.description && (
|
||||
<div className={`text-[11px] mt-0.5 ${isSelected ? 'text-[var(--glass-tone-info-fg)] opacity-80' : 'text-[var(--glass-text-tertiary)]'}`}>
|
||||
{opt.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<AppIcon name="check" className="w-4 h-4 shrink-0 overflow-visible ml-2" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Variant 2: Minimalist Line / Base Style ────────────────────
|
||||
// (底部细线风格,适用于表单密集的区域,突出内容而非边框)
|
||||
|
||||
export function SelectVariantMinimal({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '请选择...',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: CustomSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP
|
||||
const spaceAbove = rect.top - VIEWPORT_EDGE_GAP
|
||||
|
||||
let openUpward = false
|
||||
let currentMaxHeight = DEFAULT_MAX_HEIGHT
|
||||
|
||||
if (spaceBelow < 200 && spaceAbove > spaceBelow) {
|
||||
openUpward = true
|
||||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)
|
||||
} else {
|
||||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)
|
||||
}
|
||||
|
||||
setPanelStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
maxHeight: currentMaxHeight,
|
||||
...(openUpward
|
||||
? { bottom: viewportHeight - rect.top + 4 }
|
||||
: { top: rect.bottom + 4 }),
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return
|
||||
updatePosition()
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
}
|
||||
}, [isOpen, updatePosition])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`group flex items-center justify-between w-full py-2 px-1 text-left transition-all border-b border-[var(--glass-stroke-base)] ${isOpen ? 'border-[var(--glass-text-primary)]' : 'hover:border-[var(--glass-text-secondary)]'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed border-[var(--glass-stroke-subtle)]' : 'cursor-pointer'} ${className}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
{selectedOption ? (
|
||||
<span className="text-sm font-medium text-[var(--glass-text-primary)] truncate">
|
||||
{selectedOption.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-[var(--glass-text-tertiary)]">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<AppIcon
|
||||
name="chevronDown"
|
||||
className={`w-4 h-4 text-[var(--glass-text-tertiary)] shrink-0 transition-all ${isOpen ? 'rotate-180 text-[var(--glass-text-primary)]' : 'group-hover:text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] border border-[var(--glass-stroke-subtle)] py-1 bg-gradient-to-b from-[var(--glass-bg-surface-strong)] to-[var(--glass-bg-surface)] backdrop-blur-md"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="overflow-y-auto custom-scrollbar px-1 py-1 max-h-full">
|
||||
{options.map((opt) => {
|
||||
const isSelected = value === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={opt.disabled}
|
||||
onClick={() => {
|
||||
if (opt.disabled) return
|
||||
onChange(opt.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center w-full px-4 py-2.5 my-0.5 rounded-md text-left transition-all ${isSelected
|
||||
? 'bg-[var(--glass-text-primary)] text-white dark:text-black dark:bg-[var(--glass-text-primary)] shadow-sm'
|
||||
: opt.disabled
|
||||
? 'text-[var(--glass-text-tertiary)] opacity-60 cursor-not-allowed'
|
||||
: 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`flex-1 min-w-0 text-sm ${isSelected ? 'font-semibold' : 'font-medium'}`}>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<AppIcon name="check" className="w-4 h-4 shrink-0 overflow-visible ml-2" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Variant 3: Ghost / Lightweight ─────────────────────────────
|
||||
// (背景透明,只有hover态有色块,适合用于工具栏、筛选器等紧凑小巧的场景)
|
||||
|
||||
export function SelectVariantGhost({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '请选择...',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: CustomSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP
|
||||
const spaceAbove = rect.top - VIEWPORT_EDGE_GAP
|
||||
|
||||
let openUpward = false
|
||||
let currentMaxHeight = DEFAULT_MAX_HEIGHT
|
||||
|
||||
if (spaceBelow < 200 && spaceAbove > spaceBelow) {
|
||||
openUpward = true
|
||||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)
|
||||
} else {
|
||||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)
|
||||
}
|
||||
|
||||
setPanelStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 180), // Ghost往往自身较小,给下拉留点宽度
|
||||
maxHeight: currentMaxHeight,
|
||||
...(openUpward
|
||||
? { bottom: viewportHeight - rect.top + 4 }
|
||||
: { top: rect.bottom + 4 }),
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return
|
||||
updatePosition()
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
}
|
||||
}, [isOpen, updatePosition])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`inline-flex items-center justify-between gap-2 px-2.5 py-1.5 rounded-lg transition-colors text-left ${isOpen ? 'bg-[var(--glass-bg-hover)]' : 'hover:bg-[var(--glass-bg-surface-strong)]'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} ${className}`}
|
||||
>
|
||||
<span className={`text-[13px] whitespace-nowrap overflow-hidden text-ellipsis ${selectedOption ? 'font-medium text-[var(--glass-text-secondary)]' : 'text-[var(--glass-text-tertiary)]'}`}>
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
<AppIcon
|
||||
name="chevronDown"
|
||||
className={`w-3.5 h-3.5 mt-0.5 text-[var(--glass-text-tertiary)] shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-lg border border-[var(--glass-stroke-subtle)] py-1"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="overflow-y-auto custom-scrollbar p-1 max-h-full space-y-0.5">
|
||||
{options.map((opt) => {
|
||||
const isSelected = value === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={opt.disabled}
|
||||
onClick={() => {
|
||||
if (opt.disabled) return
|
||||
onChange(opt.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center w-full px-2.5 py-1.5 rounded-md text-left transition-colors ${isSelected
|
||||
? 'bg-[var(--glass-bg-active)] text-[var(--glass-text-primary)]'
|
||||
: opt.disabled
|
||||
? 'text-[var(--glass-text-tertiary)] opacity-60 cursor-not-allowed'
|
||||
: 'hover:bg-[var(--glass-bg-hover)] text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`flex-1 min-w-0 text-[13px] ${isSelected ? 'font-medium' : ''}`}>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<AppIcon name="check" className="w-3.5 h-3.5 shrink-0 overflow-visible ml-2 text-[var(--glass-text-primary)]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user