feat: add props system and refactor asset library architecture
This commit is contained in:
@@ -13,7 +13,7 @@ interface GlobalAssetPickerProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSelect: (globalAssetId: string) => void
|
||||
type: 'character' | 'location' | 'voice'
|
||||
type: 'character' | 'location' | 'prop' | 'voice'
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ interface GlobalLocation {
|
||||
images: GlobalLocationImage[]
|
||||
}
|
||||
|
||||
type GlobalProp = GlobalLocation
|
||||
|
||||
interface GlobalVoice {
|
||||
id: string
|
||||
name: string
|
||||
@@ -117,41 +119,54 @@ export default function GlobalAssetPicker({
|
||||
const charactersQuery = useQuery({
|
||||
queryKey: ['global-assets', 'characters'],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch('/api/asset-hub/characters')
|
||||
const res = await apiFetch('/api/assets?scope=global&kind=character')
|
||||
if (!res.ok) throw new Error('Failed to fetch characters')
|
||||
const data = await res.json()
|
||||
return data.characters as GlobalCharacter[]
|
||||
return data.assets as GlobalCharacter[]
|
||||
},
|
||||
enabled: type === 'character',
|
||||
})
|
||||
const locationsQuery = useQuery({
|
||||
queryKey: ['global-assets', 'locations'],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch('/api/asset-hub/locations')
|
||||
const res = await apiFetch('/api/assets?scope=global&kind=location')
|
||||
if (!res.ok) throw new Error('Failed to fetch locations')
|
||||
const data = await res.json()
|
||||
return data.locations as GlobalLocation[]
|
||||
return data.assets as GlobalLocation[]
|
||||
},
|
||||
enabled: type === 'location',
|
||||
})
|
||||
const propsQuery = useQuery({
|
||||
queryKey: ['global-assets', 'props'],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch('/api/assets?scope=global&kind=prop')
|
||||
if (!res.ok) throw new Error('Failed to fetch props')
|
||||
const data = await res.json()
|
||||
return data.assets as GlobalProp[]
|
||||
},
|
||||
enabled: type === 'prop',
|
||||
})
|
||||
const voicesQuery = useQuery({
|
||||
queryKey: ['global-assets', 'voices'],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch('/api/asset-hub/voices')
|
||||
const res = await apiFetch('/api/assets?scope=global&kind=voice')
|
||||
if (!res.ok) throw new Error('Failed to fetch voices')
|
||||
const data = await res.json()
|
||||
return data.voices as GlobalVoice[]
|
||||
return data.assets as GlobalVoice[]
|
||||
},
|
||||
enabled: type === 'voice',
|
||||
})
|
||||
|
||||
const characters = (charactersQuery.data || []) as GlobalCharacter[]
|
||||
const locations = (locationsQuery.data || []) as GlobalLocation[]
|
||||
const props = (propsQuery.data || []) as GlobalProp[]
|
||||
const voices = (voicesQuery.data || []) as GlobalVoice[]
|
||||
const isLoading = type === 'character'
|
||||
? charactersQuery.isFetching
|
||||
: type === 'location'
|
||||
? locationsQuery.isFetching
|
||||
: type === 'prop'
|
||||
? propsQuery.isFetching
|
||||
: voicesQuery.isFetching
|
||||
const loadingState = isLoading
|
||||
? resolveTaskPresentationState({
|
||||
@@ -179,6 +194,7 @@ export default function GlobalAssetPicker({
|
||||
// 提取稳定的 refetch 引用,避免 useEffect 无限循环
|
||||
const refetchCharacters = charactersQuery.refetch
|
||||
const refetchLocations = locationsQuery.refetch
|
||||
const refetchProps = propsQuery.refetch
|
||||
const refetchVoices = voicesQuery.refetch
|
||||
|
||||
// 停止音频播放的辅助函数
|
||||
@@ -200,6 +216,8 @@ export default function GlobalAssetPicker({
|
||||
refetchCharacters()
|
||||
} else if (type === 'location') {
|
||||
refetchLocations()
|
||||
} else if (type === 'prop') {
|
||||
refetchProps()
|
||||
} else {
|
||||
refetchVoices()
|
||||
}
|
||||
@@ -225,6 +243,10 @@ export default function GlobalAssetPicker({
|
||||
l.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const filteredProps = props.filter(l =>
|
||||
l.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const filteredVoices = voices.filter(v =>
|
||||
v.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(v.description && v.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
@@ -263,8 +285,20 @@ export default function GlobalAssetPicker({
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const items = type === 'character' ? filteredCharacters : type === 'location' ? filteredLocations : filteredVoices
|
||||
const hasNoAssets = type === 'character' ? characters.length === 0 : type === 'location' ? locations.length === 0 : voices.length === 0
|
||||
const items = type === 'character'
|
||||
? filteredCharacters
|
||||
: type === 'location'
|
||||
? filteredLocations
|
||||
: type === 'prop'
|
||||
? filteredProps
|
||||
: filteredVoices
|
||||
const hasNoAssets = type === 'character'
|
||||
? characters.length === 0
|
||||
: type === 'location'
|
||||
? locations.length === 0
|
||||
: type === 'prop'
|
||||
? props.length === 0
|
||||
: voices.length === 0
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50">
|
||||
@@ -272,7 +306,7 @@ export default function GlobalAssetPicker({
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--glass-stroke-base)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--glass-text-primary)]">
|
||||
{type === 'character' ? t('selectCharacter') : type === 'location' ? t('selectLocation') : t('selectVoice')}
|
||||
{type === 'character' ? t('selectCharacter') : type === 'location' ? t('selectLocation') : type === 'prop' ? t('selectProp') : t('selectVoice')}
|
||||
</h2>
|
||||
<button onClick={onClose} className="glass-btn-base glass-btn-soft text-[var(--glass-text-tertiary)]">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
@@ -303,7 +337,7 @@ export default function GlobalAssetPicker({
|
||||
<div className="flex flex-col items-center justify-center h-40 text-[var(--glass-text-tertiary)]">
|
||||
{type === 'character' ? (
|
||||
<UserIcon className="w-12 h-12 mb-2" />
|
||||
) : type === 'location' ? (
|
||||
) : type === 'location' || type === 'prop' ? (
|
||||
<PhotoIcon className="w-12 h-12 mb-2" />
|
||||
) : (
|
||||
<MicrophoneIcon className="w-12 h-12 mb-2" />
|
||||
@@ -412,6 +446,48 @@ export default function GlobalAssetPicker({
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : type === 'prop' ? (
|
||||
filteredProps.map((prop) => {
|
||||
const propPreview = getLocationPreview(prop)
|
||||
return (
|
||||
<div
|
||||
key={prop.id}
|
||||
onClick={() => setSelectedId(prop.id)}
|
||||
className={`relative cursor-pointer rounded-xl border-2 p-2 transition-all hover:shadow-md ${selectedId === prop.id
|
||||
? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)]'
|
||||
: 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'
|
||||
}`}
|
||||
>
|
||||
{selectedId === prop.id && (
|
||||
<CheckCircleIcon className="absolute -top-2 -right-2 w-6 h-6 text-[var(--glass-tone-info-fg)] bg-[var(--glass-bg-surface)] rounded-full" />
|
||||
)}
|
||||
<div className="aspect-video rounded-lg overflow-hidden bg-[var(--glass-bg-muted)] mb-2 relative">
|
||||
{propPreview ? (
|
||||
<MediaImageWithLoading
|
||||
src={propPreview}
|
||||
alt={prop.name}
|
||||
containerClassName="w-full h-full"
|
||||
className="w-full h-full object-cover cursor-zoom-in"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setPreviewImage(propPreview)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-[var(--glass-text-tertiary)]">
|
||||
<PhotoIcon className="w-12 h-12" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-sm text-[var(--glass-text-primary)] truncate">{prop.name}</p>
|
||||
<p className="text-xs text-[var(--glass-text-secondary)] mt-1">
|
||||
{prop.images?.length || 0} {t('images')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
// 音色列表渲染 - 与资产中心 VoiceCard 风格统一
|
||||
filteredVoices.map((voice) => {
|
||||
|
||||
165
src/components/shared/assets/PropCreationModal.tsx
Normal file
165
src/components/shared/assets/PropCreationModal.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { useAssetActions } from '@/lib/query/hooks'
|
||||
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
||||
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
|
||||
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
|
||||
|
||||
export interface PropCreationModalProps {
|
||||
mode: 'asset-hub' | 'project'
|
||||
folderId?: string | null
|
||||
projectId?: string
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function PropCreationModal({
|
||||
mode,
|
||||
folderId,
|
||||
projectId,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: PropCreationModalProps) {
|
||||
const t = useTranslations('assetModal')
|
||||
const actions = useAssetActions({
|
||||
scope: mode === 'asset-hub' ? 'global' : 'project',
|
||||
projectId,
|
||||
kind: 'prop',
|
||||
})
|
||||
const { count, setCount } = useImageGenerationCount('location')
|
||||
const [name, setName] = useState('')
|
||||
const [summary, setSummary] = useState('')
|
||||
const [artStyle, setArtStyle] = useState('american-comic')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const submittingState = isSubmitting
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'generate',
|
||||
resource: 'image',
|
||||
hasOutput: false,
|
||||
})
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !isSubmitting) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [isSubmitting, onClose])
|
||||
|
||||
const handleSubmit = async (generateAfterCreate: boolean) => {
|
||||
if (!name.trim() || !summary.trim()) return
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
const result = await actions.create({
|
||||
name: name.trim(),
|
||||
summary: summary.trim(),
|
||||
folderId,
|
||||
artStyle,
|
||||
}) as { assetId?: string }
|
||||
if (generateAfterCreate) {
|
||||
if (!result.assetId) {
|
||||
throw new Error('Missing assetId from create response')
|
||||
}
|
||||
await actions.generate({
|
||||
id: result.assetId,
|
||||
artStyle,
|
||||
count,
|
||||
})
|
||||
}
|
||||
onSuccess()
|
||||
onClose()
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
|
||||
<div className="glass-surface-modal max-w-2xl w-full max-h-[85vh] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">
|
||||
{t('prop.title')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-soft w-8 h-8 rounded-full flex items-center justify-center text-[var(--glass-text-tertiary)]"
|
||||
>
|
||||
<AppIcon name="close" className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('prop.name')} <span className="text-[var(--glass-tone-danger-fg)]">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={t('prop.namePlaceholder')}
|
||||
className="glass-input-base w-full px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('prop.summary')} <span className="text-[var(--glass-tone-danger-fg)]">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
placeholder={t('prop.summaryPlaceholder')}
|
||||
className="glass-textarea-base w-full h-36 px-3 py-2 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-xl flex-shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSubmit(false)}
|
||||
disabled={isSubmitting || !name.trim() || !summary.trim()}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<TaskStatusInline state={submittingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<span>{mode === 'asset-hub' ? t('common.addOnlyToAssetHubProp') : t('common.addOnlyProp')}</span>
|
||||
)}
|
||||
</button>
|
||||
<ImageGenerationInlineCountButton
|
||||
prefix={<span>{t('common.addAndGeneratePrefix')}</span>}
|
||||
suffix={<span>{t('common.generateCountSuffix')}</span>}
|
||||
value={count}
|
||||
options={getImageGenerationCountOptions('location')}
|
||||
onValueChange={setCount}
|
||||
onClick={() => void handleSubmit(true)}
|
||||
actionDisabled={!name.trim() || !summary.trim()}
|
||||
selectDisabled={isSubmitting}
|
||||
ariaLabel={t('common.selectGenerateCount')}
|
||||
className="glass-btn-base glass-btn-primary flex items-center justify-center gap-1 rounded-lg px-4 py-2 text-sm disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-sm font-semibold text-current outline-none cursor-pointer leading-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
src/components/shared/assets/PropEditModal.tsx
Normal file
157
src/components/shared/assets/PropEditModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { useAssetActions } from '@/lib/query/hooks'
|
||||
|
||||
export interface PropEditModalProps {
|
||||
mode: 'asset-hub' | 'project'
|
||||
propId: string
|
||||
propName: string
|
||||
summary: string
|
||||
variantId?: string
|
||||
projectId?: string
|
||||
onClose: () => void
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
export function PropEditModal({
|
||||
mode,
|
||||
propId,
|
||||
propName,
|
||||
summary,
|
||||
variantId,
|
||||
projectId,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: PropEditModalProps) {
|
||||
const t = useTranslations('assets')
|
||||
const actions = useAssetActions({
|
||||
scope: mode === 'asset-hub' ? 'global' : 'project',
|
||||
projectId,
|
||||
kind: 'prop',
|
||||
})
|
||||
const [editingName, setEditingName] = useState(propName)
|
||||
const [editingSummary, setEditingSummary] = useState(summary)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const savingState = isSaving
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'process',
|
||||
resource: 'text',
|
||||
hasOutput: false,
|
||||
})
|
||||
: null
|
||||
|
||||
const persist = async () => {
|
||||
await actions.update(propId, {
|
||||
name: editingName.trim(),
|
||||
summary: editingSummary.trim(),
|
||||
})
|
||||
if (variantId) {
|
||||
await actions.updateVariant(propId, variantId, {
|
||||
description: editingSummary.trim(),
|
||||
})
|
||||
}
|
||||
onRefresh?.()
|
||||
}
|
||||
|
||||
const handleSaveOnly = async () => {
|
||||
if (!editingName.trim() || !editingSummary.trim()) return
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await persist()
|
||||
onClose()
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAndGenerate = async () => {
|
||||
if (!editingName.trim() || !editingSummary.trim()) return
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await persist()
|
||||
await actions.generate({ id: propId })
|
||||
onClose()
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
|
||||
<div className="glass-surface-modal max-w-2xl w-full max-h-[80vh] flex flex-col">
|
||||
<div className="p-6 space-y-4 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">
|
||||
{t('modal.editProp')} - {propName}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-soft w-9 h-9 rounded-full text-[var(--glass-text-tertiary)]"
|
||||
>
|
||||
<AppIcon name="close" className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('prop.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(event) => setEditingName(event.target.value)}
|
||||
className="glass-input-base w-full px-3 py-2"
|
||||
placeholder={t('modal.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('prop.summary')}
|
||||
</label>
|
||||
<textarea
|
||||
value={editingSummary}
|
||||
onChange={(event) => setEditingSummary(event.target.value)}
|
||||
className="glass-textarea-base w-full h-48 px-3 py-2 resize-none"
|
||||
placeholder={t('prop.summaryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-lg flex-shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSaveOnly()}
|
||||
disabled={isSaving || !editingName.trim() || !editingSummary.trim()}
|
||||
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<TaskStatusInline state={savingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
t('modal.saveOnly')
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSaveAndGenerate()}
|
||||
disabled={isSaving || !editingName.trim() || !editingSummary.trim()}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('modal.saveAndGenerate')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
export { CharacterCreationModal } from './CharacterCreationModal'
|
||||
export { LocationCreationModal } from './LocationCreationModal'
|
||||
export { PropCreationModal } from './PropCreationModal'
|
||||
export { CharacterEditModal } from './CharacterEditModal'
|
||||
export { LocationEditModal } from './LocationEditModal'
|
||||
export { PropEditModal } from './PropEditModal'
|
||||
export type { CharacterCreationModalProps } from './CharacterCreationModal'
|
||||
export type { LocationCreationModalProps } from './LocationCreationModal'
|
||||
export type { PropCreationModalProps } from './PropCreationModal'
|
||||
export type { CharacterEditModalProps } from './CharacterEditModal'
|
||||
export type { LocationEditModalProps } from './LocationEditModal'
|
||||
|
||||
export type { PropEditModalProps } from './PropEditModal'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useRef, useState, useEffect, type ReactNode } from 'react'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────
|
||||
|
||||
@@ -20,11 +20,12 @@ interface SegmentedControlProps<T extends string = string> {
|
||||
// ─── Component ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Unified iOS-style segmented control.
|
||||
* Unified iOS-style segmented control with sliding pill indicator.
|
||||
*
|
||||
* 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.
|
||||
* Indicator lives inside the grid container to share the same
|
||||
* positioning context as buttons — guaranteeing equal padding
|
||||
* on all four sides (Apple-style).
|
||||
*/
|
||||
export function SegmentedControl<T extends string = string>({
|
||||
options,
|
||||
@@ -32,20 +33,39 @@ export function SegmentedControl<T extends string = string>({
|
||||
onChange,
|
||||
className = '',
|
||||
}: SegmentedControlProps<T>) {
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const [indicator, setIndicator] = useState<{ left: number; width: number }>({ left: 0, width: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
if (!gridRef.current) return
|
||||
const activeIndex = options.findIndex((opt) => opt.value === value)
|
||||
const buttons = gridRef.current.querySelectorAll<HTMLButtonElement>('button')
|
||||
const activeButton = buttons[activeIndex]
|
||||
if (activeButton) {
|
||||
setIndicator({ left: activeButton.offsetLeft, width: activeButton.offsetWidth })
|
||||
}
|
||||
}, [value, options])
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg p-[3px] bg-[#f2f2f7] dark:bg-[#1c1c1e] shadow-inner ${className}`}>
|
||||
<div className={`rounded-xl p-[3px] bg-[#e8e8ed] dark:bg-[#1c1c1e] ${className}`}>
|
||||
<div
|
||||
className="grid"
|
||||
ref={gridRef}
|
||||
className="relative grid"
|
||||
style={{ gridTemplateColumns: `repeat(${Math.max(1, options.length)}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{/* Sliding pill indicator */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 rounded-[10px] bg-white dark:bg-[#3a3a3c] shadow-[0_1px_3px_rgba(0,0,0,0.08),0_4px_12px_rgba(0,0,0,0.05)] transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
style={{ left: indicator.left, width: indicator.width }}
|
||||
/>
|
||||
{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)]'
|
||||
className={`relative z-10 flex items-center justify-center gap-1.5 rounded-[10px] px-3 py-1.5 text-[13px] font-semibold transition-colors duration-200 cursor-pointer ${value === opt.value
|
||||
? 'text-[#1d1d1f] dark:text-white'
|
||||
: 'text-[#86868b] hover:text-[#6e6e73]'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
|
||||
Reference in New Issue
Block a user