feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View 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

View 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'

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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}
/>
)
}

View 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'

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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} />
}

View 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} />
}

View 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

View 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'

View 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

View 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
)}
</>
)
}

View 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
)}
</>
)
}

View 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
)}
</>
)
}

View 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>
)
}

View 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)]">&ldquo;{panelData.sourceText}&rdquo;</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>
)
}

View 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>
)
}

View 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'

View File

@@ -0,0 +1 @@
export type UiPatternMode = 'flow'

View 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

View 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>
)
}

View 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>
)
}

View 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

View 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
)
}

View 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>
)
}

View 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

View 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'

View 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
)}
</>
)
}