style: polish UI and improve UX
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"downloadSuccess": "Download Complete",
|
"downloadSuccess": "Download Complete",
|
||||||
"downloadFailed": "Download Failed",
|
"downloadFailed": "Download Failed",
|
||||||
"downloadEmpty": "No image assets to download",
|
"downloadEmpty": "No image assets to download",
|
||||||
|
"filteredEmptyHint": "Click \"New Asset\" to add assets",
|
||||||
"newFolder": "New Folder",
|
"newFolder": "New Folder",
|
||||||
"editFolder": "Edit Folder",
|
"editFolder": "Edit Folder",
|
||||||
"deleteFolder": "Delete Folder",
|
"deleteFolder": "Delete Folder",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "Quick Start",
|
"title": "From Inspiration to Screen",
|
||||||
"subtitle": "Describe your story and let AI generate cinematic short dramas",
|
"subtitle": "Describe your story and let AI generate cinematic short dramas",
|
||||||
"inputPlaceholder": "Enter your story idea, novel excerpt, or script outline...",
|
"inputPlaceholder": "Enter your story idea, novel excerpt, or script outline...",
|
||||||
"startCreation": "Start Creating",
|
"startCreation": "Start Creating",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"downloadSuccess": "下载完成",
|
"downloadSuccess": "下载完成",
|
||||||
"downloadFailed": "下载失败",
|
"downloadFailed": "下载失败",
|
||||||
"downloadEmpty": "当前没有可下载的图片资产",
|
"downloadEmpty": "当前没有可下载的图片资产",
|
||||||
|
"filteredEmptyHint": "点击新建资产添加资产",
|
||||||
"newFolder": "新建文件夹",
|
"newFolder": "新建文件夹",
|
||||||
"editFolder": "编辑文件夹",
|
"editFolder": "编辑文件夹",
|
||||||
"deleteFolder": "删除文件夹",
|
"deleteFolder": "删除文件夹",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "快速开始",
|
"title": "从灵感到银幕",
|
||||||
"subtitle": "描述你想要创作的故事,AI 为你智能生成影视短剧",
|
"subtitle": "描述你想要创作的故事,AI 为你智能生成影视短剧",
|
||||||
"inputPlaceholder": "输入你的故事创意、小说片段或剧本大纲...",
|
"inputPlaceholder": "输入你的故事创意、小说片段或剧本大纲...",
|
||||||
"startCreation": "开始创作",
|
"startCreation": "开始创作",
|
||||||
|
|||||||
@@ -4,21 +4,20 @@
|
|||||||
* 首页 - 创作中心
|
* 首页 - 创作中心
|
||||||
* 用户登录后的主入口页面:快速创作 + 最近项目
|
* 用户登录后的主入口页面:快速创作 + 最近项目
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import Navbar from '@/components/Navbar'
|
import Navbar from '@/components/Navbar'
|
||||||
import { AppIcon, IconGradientDefs } from '@/components/ui/icons'
|
import { AppIcon, IconGradientDefs } from '@/components/ui/icons'
|
||||||
import { RatioSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
|
||||||
|
import TypewriterHero from '@/components/home/TypewriterHero'
|
||||||
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
||||||
|
import { DEFAULT_STYLE_PRESET_VALUE, STYLE_PRESETS } from '@/lib/style-presets'
|
||||||
import { Link, useRouter } from '@/i18n/navigation'
|
import { Link, useRouter } from '@/i18n/navigation'
|
||||||
import { apiFetch } from '@/lib/api-fetch'
|
import { apiFetch } from '@/lib/api-fetch'
|
||||||
import { expandHomeStory } from '@/lib/home/ai-story-expand'
|
import { expandHomeStory } from '@/lib/home/ai-story-expand'
|
||||||
import { createHomeProjectLaunch } from '@/lib/home/create-project-launch'
|
import { createHomeProjectLaunch } from '@/lib/home/create-project-launch'
|
||||||
import {
|
import { HOME_QUICK_START_MIN_ROWS } from '@/lib/ui/textarea-height'
|
||||||
HOME_QUICK_START_MIN_ROWS,
|
|
||||||
resolveTextareaTargetHeight,
|
|
||||||
} from '@/lib/home/quick-start-textarea'
|
|
||||||
import AiWriteModal from '@/components/home/AiWriteModal'
|
import AiWriteModal from '@/components/home/AiWriteModal'
|
||||||
|
|
||||||
interface ProjectStats {
|
interface ProjectStats {
|
||||||
@@ -51,48 +50,10 @@ export default function HomePage() {
|
|||||||
const [inputValue, setInputValue] = useState('')
|
const [inputValue, setInputValue] = useState('')
|
||||||
const [videoRatio, setVideoRatio] = useState('9:16')
|
const [videoRatio, setVideoRatio] = useState('9:16')
|
||||||
const [artStyle, setArtStyle] = useState('american-comic')
|
const [artStyle, setArtStyle] = useState('american-comic')
|
||||||
|
const [stylePresetValue, setStylePresetValue] = useState<string>(DEFAULT_STYLE_PRESET_VALUE)
|
||||||
const [createLoading, setCreateLoading] = useState(false)
|
const [createLoading, setCreateLoading] = useState(false)
|
||||||
const [aiWriteOpen, setAiWriteOpen] = useState(false)
|
const [aiWriteOpen, setAiWriteOpen] = useState(false)
|
||||||
const [aiWriteLoading, setAiWriteLoading] = useState(false)
|
const [aiWriteLoading, setAiWriteLoading] = useState(false)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
||||||
const textareaMinHeightRef = useRef<number | null>(null)
|
|
||||||
|
|
||||||
// textarea 自适应高度(rAF 分帧动画)
|
|
||||||
const autoResizeTextarea = useCallback(() => {
|
|
||||||
const el = textareaRef.current
|
|
||||||
if (!el) return
|
|
||||||
const maxH = window.innerHeight * 0.5
|
|
||||||
const oldH = el.offsetHeight
|
|
||||||
const oldScrollTop = el.scrollTop
|
|
||||||
if (textareaMinHeightRef.current === null && oldH > 0) {
|
|
||||||
textareaMinHeightRef.current = oldH
|
|
||||||
}
|
|
||||||
const minH = textareaMinHeightRef.current ?? oldH
|
|
||||||
|
|
||||||
// 同步:测量真实高度(不改 overflow,避免 scrollTop 被重置)
|
|
||||||
el.style.transition = 'none'
|
|
||||||
el.style.height = 'auto'
|
|
||||||
const scrollH = el.scrollHeight
|
|
||||||
const targetH = resolveTextareaTargetHeight({
|
|
||||||
minHeight: minH,
|
|
||||||
maxHeight: maxH,
|
|
||||||
scrollHeight: scrollH,
|
|
||||||
})
|
|
||||||
el.style.height = `${oldH}px`
|
|
||||||
el.scrollTop = oldScrollTop
|
|
||||||
|
|
||||||
// 下一帧:开启 transition → 动画到目标高度
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
el.scrollTop = oldScrollTop
|
|
||||||
el.style.transition = 'height 200ms ease-out'
|
|
||||||
el.style.height = `${targetH}px`
|
|
||||||
el.style.overflowY = scrollH > maxH ? 'auto' : 'hidden'
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
autoResizeTextarea()
|
|
||||||
}, [inputValue, autoResizeTextarea])
|
|
||||||
|
|
||||||
// 鉴权
|
// 鉴权
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -183,7 +144,6 @@ export default function HomePage() {
|
|||||||
() => ART_STYLES.map((s) => ({ ...s, recommended: s.value === 'realistic' })),
|
() => ART_STYLES.map((s) => ({ ...s, recommended: s.value === 'realistic' })),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 时间格式化
|
// 时间格式化
|
||||||
const formatTimeAgo = (dateString: string): string => {
|
const formatTimeAgo = (dateString: string): string => {
|
||||||
const diffMs = Date.now() - new Date(dateString).getTime()
|
const diffMs = Date.now() - new Date(dateString).getTime()
|
||||||
@@ -228,97 +188,105 @@ export default function HomePage() {
|
|||||||
45% { transform: translate(-15px, -20px) scale(1.15); opacity: 0.7; }
|
45% { transform: translate(-15px, -20px) scale(1.15); opacity: 0.7; }
|
||||||
70% { transform: translate(10px, -10px) scale(1); opacity: 0.35; }
|
70% { transform: translate(10px, -10px) scale(1); opacity: 0.35; }
|
||||||
}
|
}
|
||||||
|
@keyframes bracket-breathe {
|
||||||
|
0%, 70%, 100% { opacity: 0.2; }
|
||||||
|
75%, 90% { opacity: 0.6; }
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<main className="flex flex-col items-center pt-[16vh] pb-12 px-4 max-w-3xl mx-auto w-full">
|
<main className="flex flex-col items-center pt-[13vh] pb-12 px-4 max-w-5xl mx-auto w-full">
|
||||||
<div className="mb-6 text-center">
|
|
||||||
<h1 className="text-3xl font-bold text-[var(--glass-text-primary)] mb-2">
|
|
||||||
✨ {t('title')}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-[var(--glass-text-tertiary)]">{t('subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 呼吸光晕 + 输入区域 */}
|
{/* ─── 取景器整体包裹:标题 + 输入框 ─── */}
|
||||||
<div className="w-full relative group">
|
<div className="w-full relative p-5">
|
||||||
<div
|
{/* 四角校准线 */}
|
||||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
<span className="absolute top-0 left-0 w-5 h-5 border-t border-l border-[var(--glass-text-primary)] pointer-events-none z-10" style={{ animation: 'bracket-breathe 8s ease-in-out infinite' }} />
|
||||||
style={{
|
<span className="absolute top-0 right-0 w-5 h-5 border-t border-r border-[var(--glass-text-primary)] pointer-events-none z-10" style={{ animation: 'bracket-breathe 8s ease-in-out infinite' }} />
|
||||||
background: 'radial-gradient(ellipse 80% 60% at 30% 40%, rgba(6, 182, 212, 0.4), transparent 70%)',
|
<span className="absolute bottom-0 left-0 w-5 h-5 border-b border-l border-[var(--glass-text-primary)] pointer-events-none z-10" style={{ animation: 'bracket-breathe 8s ease-in-out infinite' }} />
|
||||||
animation: 'breathe-drift-1 8s ease-in-out infinite',
|
<span className="absolute bottom-0 right-0 w-5 h-5 border-b border-r border-[var(--glass-text-primary)] pointer-events-none z-10" style={{ animation: 'bracket-breathe 8s ease-in-out infinite' }} />
|
||||||
filter: 'blur(30px)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
|
||||||
style={{
|
|
||||||
background: 'radial-gradient(ellipse 70% 80% at 70% 60%, rgba(139, 92, 246, 0.35), transparent 70%)',
|
|
||||||
animation: 'breathe-drift-2 10s ease-in-out infinite',
|
|
||||||
filter: 'blur(35px)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute -inset-12 rounded-[56px] pointer-events-none"
|
|
||||||
style={{
|
|
||||||
background: 'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(59, 130, 246, 0.3), transparent 70%)',
|
|
||||||
animation: 'breathe-drift-3 12s ease-in-out infinite',
|
|
||||||
filter: 'blur(40px)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative w-full glass-surface-elevated rounded-2xl">
|
{/* REC 录制指示灯 */}
|
||||||
<textarea
|
<span
|
||||||
ref={textareaRef}
|
className="absolute top-2 right-7 flex items-center gap-1 z-10"
|
||||||
value={inputValue}
|
style={{ animation: 'bracket-breathe 2s ease-in-out infinite' }}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
>
|
||||||
placeholder={t('inputPlaceholder')}
|
<span className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_4px_rgba(239,68,68,0.7)]" />
|
||||||
rows={HOME_QUICK_START_MIN_ROWS}
|
<span className="text-[8px] font-mono font-bold tracking-widest text-red-500/70">REC</span>
|
||||||
className="w-full bg-transparent border-none outline-none text-[var(--glass-text-primary)] placeholder:text-[var(--glass-text-tertiary)] text-base resize-none p-5 pb-3 custom-scrollbar"
|
</span>
|
||||||
|
|
||||||
|
{/* 标题区 */}
|
||||||
|
<TypewriterHero title={t('title')} subtitle={t('subtitle')} />
|
||||||
|
|
||||||
|
{/* 呼吸光晕 + 输入区域 */}
|
||||||
|
<div className="w-full relative group">
|
||||||
|
<div
|
||||||
|
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(ellipse 80% 60% at 30% 40%, rgba(6, 182, 212, 0.4), transparent 70%)',
|
||||||
|
animation: 'breathe-drift-1 8s ease-in-out infinite',
|
||||||
|
filter: 'blur(30px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(ellipse 70% 80% at 70% 60%, rgba(139, 92, 246, 0.35), transparent 70%)',
|
||||||
|
animation: 'breathe-drift-2 10s ease-in-out infinite',
|
||||||
|
filter: 'blur(35px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -inset-12 rounded-[56px] pointer-events-none"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(59, 130, 246, 0.3), transparent 70%)',
|
||||||
|
animation: 'breathe-drift-3 12s ease-in-out infinite',
|
||||||
|
filter: 'blur(40px)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 底部工具栏:比例 + 风格 + AI帮我写 + 创建按钮 */}
|
<StoryInputComposer
|
||||||
<div className="flex items-end gap-3 px-5 pb-4">
|
value={inputValue}
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
onValueChange={setInputValue}
|
||||||
<div className="w-[160px] flex-shrink-0">
|
placeholder={t('inputPlaceholder')}
|
||||||
<RatioSelector
|
minRows={HOME_QUICK_START_MIN_ROWS}
|
||||||
value={videoRatio}
|
videoRatio={videoRatio}
|
||||||
onChange={setVideoRatio}
|
onVideoRatioChange={setVideoRatio}
|
||||||
options={ratioOptions}
|
ratioOptions={ratioOptions}
|
||||||
/>
|
artStyle={artStyle}
|
||||||
</div>
|
onArtStyleChange={setArtStyle}
|
||||||
<div className="w-[160px] flex-shrink-0">
|
styleOptions={styleOptions}
|
||||||
<StyleSelector
|
stylePresetValue={stylePresetValue}
|
||||||
value={artStyle}
|
onStylePresetChange={setStylePresetValue}
|
||||||
onChange={setArtStyle}
|
stylePresetOptions={STYLE_PRESETS}
|
||||||
options={styleOptions}
|
primaryAction={(
|
||||||
/>
|
<button
|
||||||
</div>
|
onClick={() => void handleCreate()}
|
||||||
</div>
|
disabled={!inputValue.trim() || createLoading}
|
||||||
<button
|
className="glass-btn-base glass-btn-primary h-10 flex-shrink-0 px-5 text-sm disabled:opacity-50"
|
||||||
onClick={() => setAiWriteOpen(true)}
|
|
||||||
disabled={createLoading}
|
|
||||||
className="glass-btn-base px-4 py-2.5 text-sm flex-shrink-0 border border-[var(--glass-stroke-strong)] hover:border-[var(--glass-tone-info-fg)]/40 transition-all flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<AppIcon name="sparkles" className="w-4 h-4 text-[#7c3aed]" />
|
|
||||||
<span
|
|
||||||
className="font-medium"
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
|
|
||||||
WebkitBackgroundClip: 'text',
|
|
||||||
WebkitTextFillColor: 'transparent',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('aiWrite.trigger')}
|
{createLoading ? tc('loading') : t('startCreation')}
|
||||||
</span>
|
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
)}
|
||||||
onClick={() => void handleCreate()}
|
secondaryActions={(
|
||||||
disabled={!inputValue.trim() || createLoading}
|
<button
|
||||||
className="glass-btn-base glass-btn-primary px-5 py-2.5 text-sm flex-shrink-0 disabled:opacity-50"
|
onClick={() => setAiWriteOpen(true)}
|
||||||
>
|
disabled={createLoading}
|
||||||
{createLoading ? tc('loading') : t('startCreation')}
|
className="glass-btn-base flex h-10 flex-shrink-0 items-center gap-1.5 border border-[var(--glass-stroke-strong)] px-3 text-sm transition-all hover:border-[var(--glass-tone-info-fg)]/40"
|
||||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
>
|
||||||
</button>
|
<AppIcon name="sparkles" className="w-4 h-4 text-[#7c3aed]" />
|
||||||
</div>
|
<span
|
||||||
|
className="font-medium"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('aiWrite.trigger')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* AI 帮我写模态框 */}
|
{/* AI 帮我写模态框 */}
|
||||||
|
|||||||
@@ -8,11 +8,16 @@
|
|||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import '@/styles/animations.css'
|
import '@/styles/animations.css'
|
||||||
|
import AiWriteModal from '@/components/home/AiWriteModal'
|
||||||
|
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
|
||||||
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
||||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||||
import { AppIcon } from '@/components/ui/icons'
|
import { AppIcon } from '@/components/ui/icons'
|
||||||
import { RatioSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
import { DEFAULT_STYLE_PRESET_VALUE, STYLE_PRESETS } from '@/lib/style-presets'
|
||||||
|
import { PROJECT_STORY_INPUT_MIN_ROWS } from '@/lib/ui/textarea-height'
|
||||||
|
import { apiFetch } from '@/lib/api-fetch'
|
||||||
|
import { expandHomeStory } from '@/lib/home/ai-story-expand'
|
||||||
|
|
||||||
/** 触发智能分集建议的字数阈值 */
|
/** 触发智能分集建议的字数阈值 */
|
||||||
const LONG_TEXT_THRESHOLD = 1000
|
const LONG_TEXT_THRESHOLD = 1000
|
||||||
@@ -58,6 +63,7 @@ export default function NovelInputStage({
|
|||||||
onArtStyleChange
|
onArtStyleChange
|
||||||
}: NovelInputStageProps) {
|
}: NovelInputStageProps) {
|
||||||
const t = useTranslations('novelPromotion')
|
const t = useTranslations('novelPromotion')
|
||||||
|
const homeT = useTranslations('home')
|
||||||
|
|
||||||
// ── IME 组合输入处理 ──
|
// ── IME 组合输入处理 ──
|
||||||
// 中文/日文/韩文输入法在组合(composing)期间会持续触发 onChange,
|
// 中文/日文/韩文输入法在组合(composing)期间会持续触发 onChange,
|
||||||
@@ -66,6 +72,9 @@ export default function NovelInputStage({
|
|||||||
// 解决方案:组合期间仅更新本地 state,组合结束后再同步到父组件。
|
// 解决方案:组合期间仅更新本地 state,组合结束后再同步到父组件。
|
||||||
const isComposingRef = useRef(false)
|
const isComposingRef = useRef(false)
|
||||||
const [localText, setLocalText] = useState(novelText)
|
const [localText, setLocalText] = useState(novelText)
|
||||||
|
const [stylePresetValue, setStylePresetValue] = useState<string>(DEFAULT_STYLE_PRESET_VALUE)
|
||||||
|
const [aiWriteOpen, setAiWriteOpen] = useState(false)
|
||||||
|
const [aiWriteLoading, setAiWriteLoading] = useState(false)
|
||||||
|
|
||||||
// 当父组件的 novelText 变化(非本地编辑触发)时,同步到本地 state
|
// 当父组件的 novelText 变化(非本地编辑触发)时,同步到本地 state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,15 +83,6 @@ export default function NovelInputStage({
|
|||||||
}
|
}
|
||||||
}, [novelText])
|
}, [novelText])
|
||||||
|
|
||||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const newValue = e.target.value
|
|
||||||
setLocalText(newValue)
|
|
||||||
// 仅在非 IME 组合状态下才同步到父组件
|
|
||||||
if (!isComposingRef.current) {
|
|
||||||
onNovelTextChange(newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCompositionStart = () => {
|
const handleCompositionStart = () => {
|
||||||
isComposingRef.current = true
|
isComposingRef.current = true
|
||||||
}
|
}
|
||||||
@@ -106,23 +106,25 @@ export default function NovelInputStage({
|
|||||||
}
|
}
|
||||||
}, [localText, onNext, onSmartSplit])
|
}, [localText, onNext, onSmartSplit])
|
||||||
|
|
||||||
// 当前配置展示文案
|
const handleAiWriteStart = useCallback(async (prompt: string) => {
|
||||||
const ratioDisplayLabel = (VIDEO_RATIOS.find((option) => option.value === videoRatio) ?? VIDEO_RATIOS[0])?.label
|
if (aiWriteLoading) return
|
||||||
const artStyleDisplayLabel = (ART_STYLES.find((option) => option.value === artStyle) ?? ART_STYLES[0])?.label
|
setAiWriteLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await expandHomeStory({
|
||||||
|
apiFetch,
|
||||||
|
prompt,
|
||||||
|
})
|
||||||
|
|
||||||
// 不同比例适合的素材类型文案映射(完整句子,用于 info 悬浮层)
|
setLocalText(result.expandedText)
|
||||||
const ratioUsageTextMap: Record<string, string> = {
|
onNovelTextChange(result.expandedText)
|
||||||
'1:1': t('storyInput.ratioUsage.1_1'),
|
setAiWriteOpen(false)
|
||||||
'9:16': t('storyInput.ratioUsage.9_16'),
|
} catch (error) {
|
||||||
'16:9': t('storyInput.ratioUsage.16_9'),
|
const message = error instanceof Error ? error.message : 'Failed'
|
||||||
'4:3': t('storyInput.ratioUsage.4_3'),
|
window.alert(message)
|
||||||
'3:4': t('storyInput.ratioUsage.3_4'),
|
} finally {
|
||||||
'2:3': t('storyInput.ratioUsage.2_3'),
|
setAiWriteLoading(false)
|
||||||
'3:2': t('storyInput.ratioUsage.3_2'),
|
}
|
||||||
'4:5': t('storyInput.ratioUsage.4_5'),
|
}, [aiWriteLoading, onNovelTextChange])
|
||||||
'5:4': t('storyInput.ratioUsage.5_4'),
|
|
||||||
'21:9': t('storyInput.ratioUsage.21_9'),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下拉中使用的简短标签(低信息密度)
|
// 下拉中使用的简短标签(低信息密度)
|
||||||
const ratioUsageTagMap: Record<string, string> = {
|
const ratioUsageTagMap: Record<string, string> = {
|
||||||
@@ -138,13 +140,9 @@ export default function NovelInputStage({
|
|||||||
'21:9': t('storyInput.ratioUsageTag.21_9'),
|
'21:9': t('storyInput.ratioUsageTag.21_9'),
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRatioUsageText = (ratio: string): string =>
|
|
||||||
ratioUsageTextMap[ratio] ?? t('storyInput.videoRatioHint')
|
|
||||||
|
|
||||||
const getRatioUsageTag = (ratio: string): string =>
|
const getRatioUsageTag = (ratio: string): string =>
|
||||||
ratioUsageTagMap[ratio] ?? ''
|
ratioUsageTagMap[ratio] ?? ''
|
||||||
|
|
||||||
const ratioUsageText = getRatioUsageText(videoRatio)
|
|
||||||
const stageSwitchingState = isSwitchingStage
|
const stageSwitchingState = isSwitchingStage
|
||||||
? resolveTaskPresentationState({
|
? resolveTaskPresentationState({
|
||||||
phase: 'processing',
|
phase: 'processing',
|
||||||
@@ -168,81 +166,82 @@ export default function NovelInputStage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 主输入区域(含底部工具栏) */}
|
{/* 主输入区域(含底部工具栏) */}
|
||||||
<div className="glass-surface-elevated overflow-hidden relative z-10">
|
<div className="relative z-10">
|
||||||
<div className="p-6 pb-0">
|
<StoryInputComposer
|
||||||
{/* 字数统计 */}
|
value={localText}
|
||||||
<div className="flex items-center justify-end mb-3">
|
onValueChange={(value) => {
|
||||||
<span className="glass-chip glass-chip-neutral text-xs">
|
setLocalText(value)
|
||||||
{t("storyInput.wordCount")} {localText.length}
|
if (!isComposingRef.current) {
|
||||||
</span>
|
onNovelTextChange(value)
|
||||||
</div>
|
}
|
||||||
|
}}
|
||||||
{/* 剧本输入框 */}
|
onCompositionStart={handleCompositionStart}
|
||||||
<textarea
|
onCompositionEnd={handleCompositionEnd}
|
||||||
value={localText}
|
placeholder={`请输入您的剧本或小说内容...\n\nAI 将根据您的文本智能分析:\n• 自动识别场景切换\n• 提取角色对话和动作\n• 生成分镜脚本\n\n例如:\n清晨,阳光透过窗帘洒进房间。小明揉着惺忪的睡眼从床上坐起,看了一眼床头的闹钟——已经八点了!他猛地跳下床,手忙脚乱地开始穿衣服...`}
|
||||||
onChange={handleTextChange}
|
minRows={PROJECT_STORY_INPUT_MIN_ROWS}
|
||||||
onCompositionStart={handleCompositionStart}
|
maxHeightViewportRatio={0.5}
|
||||||
onCompositionEnd={handleCompositionEnd}
|
disabled={isSubmittingTask || isSwitchingStage}
|
||||||
placeholder={`请输入您的剧本或小说内容...\n\nAI 将根据您的文本智能分析:\n• 自动识别场景切换\n• 提取角色对话和动作\n• 生成分镜脚本\n\n例如:\n清晨,阳光透过窗帘洒进房间。小明揉着惺忪的睡眼从床上坐起,看了一眼床头的闹钟——已经八点了!他猛地跳下床,手忙脚乱地开始穿衣服...`}
|
videoRatio={videoRatio}
|
||||||
className="glass-textarea-base custom-scrollbar h-80 px-4 py-3 text-base resize-none placeholder:text-[var(--glass-text-tertiary)]"
|
onVideoRatioChange={(value) => onVideoRatioChange?.(value)}
|
||||||
disabled={isSubmittingTask || isSwitchingStage}
|
ratioOptions={VIDEO_RATIOS.map((option) => ({
|
||||||
/>
|
...option,
|
||||||
</div>
|
recommended: option.value === '9:16'
|
||||||
|
}))}
|
||||||
{/* 底部工具栏:比例 + 风格 + 开始创作(内嵌在输入框卡片内) */}
|
getRatioUsage={getRatioUsageTag}
|
||||||
<div className="flex items-end gap-3 px-6 py-4">
|
artStyle={artStyle}
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
onArtStyleChange={(value) => onArtStyleChange?.(value)}
|
||||||
<div className="w-[160px] flex-shrink-0">
|
styleOptions={ART_STYLES.map((option) => ({
|
||||||
<RatioSelector
|
...option,
|
||||||
value={videoRatio}
|
recommended: option.value === 'realistic'
|
||||||
onChange={(value) => onVideoRatioChange?.(value)}
|
}))}
|
||||||
options={VIDEO_RATIOS.map((option) => ({
|
stylePresetValue={stylePresetValue}
|
||||||
...option,
|
onStylePresetChange={setStylePresetValue}
|
||||||
recommended: option.value === '9:16'
|
stylePresetOptions={STYLE_PRESETS}
|
||||||
}))}
|
textareaClassName="text-base p-5 pb-3"
|
||||||
getUsage={getRatioUsageTag}
|
primaryAction={(
|
||||||
/>
|
<button
|
||||||
</div>
|
onClick={handleStartClick}
|
||||||
<div className="w-[160px] flex-shrink-0">
|
disabled={!hasContent || isSubmittingTask || isSwitchingStage}
|
||||||
<StyleSelector
|
className="glass-btn-base glass-btn-primary h-10 flex-shrink-0 px-5 text-sm disabled:opacity-50 flex items-center gap-2"
|
||||||
value={artStyle}
|
>
|
||||||
onChange={(value) => onArtStyleChange?.(value)}
|
{isSwitchingStage ? (
|
||||||
options={ART_STYLES.map((option) => ({
|
<TaskStatusInline state={stageSwitchingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||||
...option,
|
) : (
|
||||||
recommended: option.value === 'realistic'
|
<>
|
||||||
}))}
|
<span>{t("smartImport.manualCreate.button")}</span>
|
||||||
/>
|
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
<button
|
</button>
|
||||||
onClick={handleStartClick}
|
)}
|
||||||
disabled={!hasContent || isSubmittingTask || isSwitchingStage}
|
secondaryActions={(
|
||||||
className="glass-btn-base glass-btn-primary px-5 py-2.5 text-sm flex-shrink-0 disabled:opacity-50 flex items-center gap-2"
|
<button
|
||||||
>
|
onClick={() => setAiWriteOpen(true)}
|
||||||
{isSwitchingStage ? (
|
disabled={isSubmittingTask || isSwitchingStage}
|
||||||
<TaskStatusInline state={stageSwitchingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
className="glass-btn-base flex h-10 flex-shrink-0 items-center gap-1.5 border border-[var(--glass-stroke-strong)] px-3 text-sm transition-all hover:border-[var(--glass-tone-info-fg)]/40"
|
||||||
) : (
|
>
|
||||||
<>
|
<AppIcon name="sparkles" className="w-4 h-4 text-[#7c3aed]" />
|
||||||
<span>{t("smartImport.manualCreate.button")}</span>
|
<span
|
||||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
className="font-medium"
|
||||||
</>
|
style={{
|
||||||
)}
|
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
|
||||||
</button>
|
WebkitBackgroundClip: 'text',
|
||||||
</div>
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}
|
||||||
{/* 配置提示 */}
|
>
|
||||||
<div className="px-6 pb-4 space-y-1 text-center">
|
{homeT('aiWrite.trigger')}
|
||||||
<p className="text-xs text-[var(--glass-text-secondary)]">
|
</span>
|
||||||
{t("storyInput.currentConfigSummary", {
|
</button>
|
||||||
ratio: ratioDisplayLabel,
|
)}
|
||||||
style: artStyleDisplayLabel
|
/>
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--glass-text-tertiary)]">
|
|
||||||
{t("storyInput.moreConfig")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<AiWriteModal
|
||||||
|
open={aiWriteOpen}
|
||||||
|
loading={aiWriteLoading}
|
||||||
|
onClose={() => setAiWriteOpen(false)}
|
||||||
|
onStart={(prompt) => void handleAiWriteStart(prompt)}
|
||||||
|
t={(key: string) => homeT(`aiWrite.${key}`)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 资产库引导提示 */}
|
{/* 资产库引导提示 */}
|
||||||
<div className="glass-surface p-4">
|
<div className="glass-surface p-4">
|
||||||
|
|||||||
@@ -282,6 +282,21 @@ export function AssetGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isEmpty = characters.length === 0 && locations.length === 0 && props.length === 0 && voices.length === 0
|
const isEmpty = characters.length === 0 && locations.length === 0 && props.length === 0 && voices.length === 0
|
||||||
|
const visibleAssetCount = (() => {
|
||||||
|
switch (filter) {
|
||||||
|
case 'character':
|
||||||
|
return characters.length
|
||||||
|
case 'location':
|
||||||
|
return locations.length
|
||||||
|
case 'prop':
|
||||||
|
return props.length
|
||||||
|
case 'voice':
|
||||||
|
return voices.length
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
return characters.length + locations.length + props.length + voices.length
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'all', label: t('allAssets') },
|
{ id: 'all', label: t('allAssets') },
|
||||||
@@ -300,6 +315,8 @@ export function AssetGrid({
|
|||||||
options={tabs.map(tab => ({ value: tab.id, label: tab.label }))}
|
options={tabs.map(tab => ({ value: tab.id, label: tab.label }))}
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(val) => setFilter(val as 'all' | 'character' | 'location' | 'prop' | 'voice')}
|
onChange={(val) => setFilter(val as 'all' | 'character' | 'location' | 'prop' | 'voice')}
|
||||||
|
layout="compact"
|
||||||
|
className="min-w-max"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 右侧操作按钮 */}
|
{/* 右侧操作按钮 */}
|
||||||
@@ -332,6 +349,20 @@ export function AssetGrid({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-[var(--glass-text-secondary)] mb-2">{t('emptyState')}</p>
|
<p className="text-[var(--glass-text-secondary)] mb-2">{t('emptyState')}</p>
|
||||||
<p className="text-sm text-[var(--glass-text-tertiary)]">{t('emptyStateHint')}</p>
|
<p className="text-sm text-[var(--glass-text-tertiary)]">{t('emptyStateHint')}</p>
|
||||||
|
<div className="mt-6 flex justify-center">
|
||||||
|
<AddAssetDropdown
|
||||||
|
onAddCharacter={onAddCharacter}
|
||||||
|
onAddLocation={onAddLocation}
|
||||||
|
onAddProp={onAddProp}
|
||||||
|
onAddVoice={onAddVoice}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : visibleAssetCount === 0 ? (
|
||||||
|
<div className="flex min-h-[320px] items-center justify-center">
|
||||||
|
<p className="text-sm text-[var(--glass-text-tertiary)]">
|
||||||
|
{t('filteredEmptyHint')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|||||||
106
src/components/home/TypewriterHero.tsx
Normal file
106
src/components/home/TypewriterHero.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypewriterHero — 标题 + 终端打字机副标题
|
||||||
|
* 对焦动画保留,取景器四角线移至页面级包裹
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
const TYPE_SPEED = 55
|
||||||
|
const DELETE_SPEED = 20
|
||||||
|
const PAUSE_AFTER_TYPE = 3200
|
||||||
|
const PAUSE_AFTER_DELETE = 500
|
||||||
|
|
||||||
|
interface TypewriterHeroProps {
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TypewriterHero({ title, subtitle }: TypewriterHeroProps) {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const prevLenRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prevLenRef.current = text.length
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeout: NodeJS.Timeout
|
||||||
|
if (!isDeleting && text.length === subtitle.length) {
|
||||||
|
timeout = setTimeout(() => setIsDeleting(true), PAUSE_AFTER_TYPE)
|
||||||
|
} else if (isDeleting && text.length === 0) {
|
||||||
|
timeout = setTimeout(() => setIsDeleting(false), PAUSE_AFTER_DELETE)
|
||||||
|
} else {
|
||||||
|
timeout = setTimeout(
|
||||||
|
() => setText(subtitle.slice(0, text.length + (isDeleting ? -1 : 1))),
|
||||||
|
isDeleting ? DELETE_SPEED : TYPE_SPEED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [text, isDeleting, subtitle])
|
||||||
|
|
||||||
|
const isNewChar = (i: number) =>
|
||||||
|
!isDeleting && i === text.length - 1 && text.length > prevLenRef.current
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<style>{`
|
||||||
|
@keyframes twh-focus-pull {
|
||||||
|
0%, 70%, 100% { filter: blur(0px); opacity: 1; }
|
||||||
|
75% { filter: blur(3px); opacity: 0.85; }
|
||||||
|
80% { filter: blur(1.5px); opacity: 0.9; }
|
||||||
|
85% { filter: blur(0.5px); opacity: 0.95; }
|
||||||
|
88% { filter: blur(1px); opacity: 0.92; }
|
||||||
|
92% { filter: blur(0px); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes twh-charIn {
|
||||||
|
0% { opacity: 0; transform: translateY(6px) scale(0.8); }
|
||||||
|
60% { opacity: 1; transform: translateY(-1px) scale(1.05); }
|
||||||
|
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes twh-hover {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-1.5px); }
|
||||||
|
}
|
||||||
|
@keyframes twh-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* 标题 — 带对焦动画 */}
|
||||||
|
<h1
|
||||||
|
className="text-3xl font-bold text-[var(--glass-text-primary)] tracking-[0.08em] mb-2"
|
||||||
|
style={{ animation: 'twh-focus-pull 8s ease-in-out infinite' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* 终端打字机副标题 */}
|
||||||
|
<p className="font-mono text-sm h-6 flex items-center justify-center" style={{ color: 'var(--glass-text-tertiary)' }}>
|
||||||
|
<span className="mr-1.5 opacity-50">>_</span>
|
||||||
|
{text.split('').map((char, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
animationName: isNewChar(i) ? 'twh-charIn' : 'twh-hover',
|
||||||
|
animationDuration: isNewChar(i) ? '0.25s' : '3s',
|
||||||
|
animationTimingFunction: isNewChar(i) ? 'ease-out' : 'ease-in-out',
|
||||||
|
animationIterationCount: isNewChar(i) ? 1 : 'infinite',
|
||||||
|
animationFillMode: isNewChar(i) ? 'forwards' : 'none',
|
||||||
|
animationDelay: isNewChar(i) ? '0s' : `${i * 0.08}s`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{char === ' ' ? '\u00A0' : char}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-4 ml-0.5 bg-[var(--glass-text-tertiary)] align-middle rounded-[1px]"
|
||||||
|
style={{ animation: 'twh-blink 1s step-end infinite' }}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,9 +6,80 @@
|
|||||||
*
|
*
|
||||||
* 使用场景:首页、项目故事输入页
|
* 使用场景:首页、项目故事输入页
|
||||||
*/
|
*/
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useState, useRef, useEffect, useLayoutEffect, useCallback, type CSSProperties } from 'react'
|
||||||
import { AppIcon } from '@/components/ui/icons'
|
import { AppIcon } from '@/components/ui/icons'
|
||||||
|
|
||||||
|
const TRIGGER_CLASSNAME = 'glass-input-base flex h-10 w-full items-center justify-between gap-2 px-2.5 transition-colors'
|
||||||
|
const TRIGGER_TEXT_CLASSNAME = 'text-[13px] font-medium text-[var(--glass-text-primary)]'
|
||||||
|
|
||||||
|
const VIEWPORT_EDGE_GAP = 8
|
||||||
|
const DEFAULT_MAX_HEIGHT = 280
|
||||||
|
|
||||||
|
function useFloatingDropdown(isOpen: boolean, minWidth: number) {
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [panelStyle, setPanelStyle] = useState<CSSProperties>({})
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (!triggerRef.current || typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect()
|
||||||
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||||
|
const viewportWidth = window.innerWidth || document.documentElement.clientWidth
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP
|
||||||
|
const spaceAbove = rect.top - VIEWPORT_EDGE_GAP
|
||||||
|
const openUpward = spaceBelow < 220 && spaceAbove > spaceBelow
|
||||||
|
const availableSpace = openUpward ? spaceAbove : spaceBelow
|
||||||
|
const width = Math.min(
|
||||||
|
Math.max(rect.width, minWidth),
|
||||||
|
viewportWidth - VIEWPORT_EDGE_GAP * 2,
|
||||||
|
)
|
||||||
|
const left = Math.min(
|
||||||
|
Math.max(VIEWPORT_EDGE_GAP, rect.left),
|
||||||
|
viewportWidth - width - VIEWPORT_EDGE_GAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
setPanelStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
maxHeight: Math.max(120, Math.min(DEFAULT_MAX_HEIGHT, availableSpace)),
|
||||||
|
...(openUpward
|
||||||
|
? { bottom: viewportHeight - rect.top + 4 }
|
||||||
|
: { top: rect.bottom + 4 }),
|
||||||
|
})
|
||||||
|
}, [minWidth])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (triggerRef.current?.contains(target)) return
|
||||||
|
if (panelRef.current?.contains(target)) return
|
||||||
|
if (!isOpen) return
|
||||||
|
setPanelStyle({})
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
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 RatioShape({ ratio, selected, size = 26 }: { ratio: string; selected: boolean; size?: number }) {
|
function RatioShape({ ratio, selected, size = 26 }: { ratio: string; selected: boolean; size?: number }) {
|
||||||
const [w, h] = ratio.split(':').map(Number)
|
const [w, h] = ratio.split(':').map(Number)
|
||||||
@@ -38,38 +109,43 @@ export function RatioSelector({
|
|||||||
getUsage?: (ratio: string) => string
|
getUsage?: (ratio: string) => string
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const { triggerRef, panelRef, panelStyle } = useFloatingDropdown(isOpen, 300)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
const target = event.target as Node
|
||||||
|
if (triggerRef.current?.contains(target)) return
|
||||||
|
if (panelRef.current?.contains(target)) return
|
||||||
|
if (isOpen) {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [])
|
}, [isOpen, panelRef, triggerRef])
|
||||||
|
|
||||||
const selectedOption = options.find((o) => o.value === value)
|
const selectedOption = options.find((o) => o.value === value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
|
className={`${TRIGGER_CLASSNAME} cursor-pointer`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<RatioShape ratio={value} size={18} selected />
|
<RatioShape ratio={value} size={18} selected />
|
||||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption?.label || value}</span>
|
<span className={`${TRIGGER_TEXT_CLASSNAME} truncate`}>{selectedOption?.label || value}</span>
|
||||||
</div>
|
</div>
|
||||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||||
<div
|
<div
|
||||||
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar"
|
ref={panelRef}
|
||||||
style={{ minWidth: '300px' }}
|
className="glass-surface-modal z-[9999] p-3 overflow-y-auto custom-scrollbar"
|
||||||
|
style={panelStyle}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
@@ -98,9 +174,10 @@ export function RatioSelector({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,33 +191,44 @@ export function StyleSelector({
|
|||||||
options: { value: string; label: string; recommended?: boolean }[]
|
options: { value: string; label: string; recommended?: boolean }[]
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const { triggerRef, panelRef, panelStyle } = useFloatingDropdown(isOpen, 320)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
const target = event.target as Node
|
||||||
|
if (triggerRef.current?.contains(target)) return
|
||||||
|
if (panelRef.current?.contains(target)) return
|
||||||
|
if (isOpen) {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [])
|
}, [isOpen, panelRef, triggerRef])
|
||||||
|
|
||||||
const selectedOption = options.find((o) => o.value === value) || options[0]
|
const selectedOption = options.find((o) => o.value === value) || options[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
|
className={`${TRIGGER_CLASSNAME} cursor-pointer`}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption.label}</span>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<AppIcon name="sparklesAlt" className="h-4 w-4 text-[var(--glass-accent-from)]" />
|
||||||
|
<span className={`${TRIGGER_TEXT_CLASSNAME} truncate`}>{selectedOption.label}</span>
|
||||||
|
</div>
|
||||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||||
<div className="glass-surface-modal absolute z-50 mt-1 left-0 p-3" style={{ minWidth: '320px' }}>
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="glass-surface-modal z-[9999] p-3"
|
||||||
|
style={panelStyle}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const isSelected = value === option.value
|
const isSelected = value === option.value
|
||||||
@@ -165,8 +253,125 @@ export function StyleSelector({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StylePresetSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
options: readonly { value: string; label: string; description: string }[]
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const { triggerRef, panelRef, panelStyle } = useFloatingDropdown(isOpen, 260)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (triggerRef.current?.contains(target)) return
|
||||||
|
if (panelRef.current?.contains(target)) return
|
||||||
|
if (isOpen) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [isOpen, panelRef, triggerRef])
|
||||||
|
|
||||||
|
const selectedOption = options.find((option) => option.value === value) ?? options[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`${TRIGGER_CLASSNAME} cursor-pointer`}
|
||||||
|
title={selectedOption.label}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<AppIcon name="clapperboard" className="h-4 w-4 shrink-0 text-[var(--glass-accent-from)]" />
|
||||||
|
<span className={`${TRIGGER_TEXT_CLASSNAME} min-w-0 flex-1 truncate`}>
|
||||||
|
{selectedOption.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<AppIcon name="chevronDown" className={`h-4 w-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="glass-surface-modal z-[9999] p-2.5"
|
||||||
|
style={panelStyle}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = value === option.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option.value)
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-[var(--glass-accent-from)] bg-[var(--glass-accent-from)]/5 shadow-sm'
|
||||||
|
: 'border-[var(--glass-stroke-soft)] hover:border-[var(--glass-stroke-strong)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className={`text-sm ${isSelected ? 'font-semibold text-[var(--glass-accent-from)]' : 'font-medium text-[var(--glass-text-primary)]'}`}>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--glass-text-tertiary)]">
|
||||||
|
{option.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<AppIcon name="check" className="h-4 w-4 shrink-0 text-[var(--glass-accent-from)]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StylePresetBadge({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="glass-input-base relative flex h-10 w-full items-center gap-2 overflow-hidden px-2.5">
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(99,102,241,0.1))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AppIcon name="clapperboard" className="relative h-4 w-4 shrink-0 text-[var(--glass-accent-from)]" />
|
||||||
|
<span className="relative min-w-0 flex-1 truncate text-[13px] font-semibold text-[var(--glass-text-primary)]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="relative shrink-0 rounded-full bg-[var(--glass-tone-info-bg)] px-1.5 py-0.5 text-[10px] font-semibold text-[var(--glass-tone-info-fg)]">
|
||||||
|
{description}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
170
src/components/story-input/StoryInputComposer.tsx
Normal file
170
src/components/story-input/StoryInputComposer.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, type CompositionEvent, type ReactNode } from 'react'
|
||||||
|
import { RatioSelector, StylePresetSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
||||||
|
import { resolveTextareaTargetHeight } from '@/lib/ui/textarea-height'
|
||||||
|
|
||||||
|
interface StoryInputComposerOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
recommended?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoryInputComposerStylePresetOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoryInputComposerProps {
|
||||||
|
value: string
|
||||||
|
onValueChange: (value: string) => void
|
||||||
|
placeholder: string
|
||||||
|
minRows: number
|
||||||
|
disabled?: boolean
|
||||||
|
maxHeightViewportRatio?: number
|
||||||
|
topRight?: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
secondaryActions?: ReactNode
|
||||||
|
primaryAction: ReactNode
|
||||||
|
videoRatio: string
|
||||||
|
onVideoRatioChange: (value: string) => void
|
||||||
|
ratioOptions: StoryInputComposerOption[]
|
||||||
|
getRatioUsage?: (ratio: string) => string
|
||||||
|
artStyle: string
|
||||||
|
onArtStyleChange: (value: string) => void
|
||||||
|
styleOptions: StoryInputComposerOption[]
|
||||||
|
stylePresetValue: string
|
||||||
|
onStylePresetChange: (value: string) => void
|
||||||
|
stylePresetOptions: readonly StoryInputComposerStylePresetOption[]
|
||||||
|
onCompositionStart?: () => void
|
||||||
|
onCompositionEnd?: (event: CompositionEvent<HTMLTextAreaElement>) => void
|
||||||
|
textareaClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StoryInputComposer({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
placeholder,
|
||||||
|
minRows,
|
||||||
|
disabled = false,
|
||||||
|
maxHeightViewportRatio = 0.5,
|
||||||
|
topRight,
|
||||||
|
footer,
|
||||||
|
secondaryActions,
|
||||||
|
primaryAction,
|
||||||
|
videoRatio,
|
||||||
|
onVideoRatioChange,
|
||||||
|
ratioOptions,
|
||||||
|
getRatioUsage,
|
||||||
|
artStyle,
|
||||||
|
onArtStyleChange,
|
||||||
|
styleOptions,
|
||||||
|
stylePresetValue,
|
||||||
|
onStylePresetChange,
|
||||||
|
stylePresetOptions,
|
||||||
|
onCompositionStart,
|
||||||
|
onCompositionEnd,
|
||||||
|
textareaClassName,
|
||||||
|
}: StoryInputComposerProps) {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const textareaMinHeightRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const autoResizeTextarea = useCallback(() => {
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (!el || typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const maxHeight = window.innerHeight * maxHeightViewportRatio
|
||||||
|
const oldHeight = el.offsetHeight
|
||||||
|
const oldScrollTop = el.scrollTop
|
||||||
|
|
||||||
|
if (textareaMinHeightRef.current === null && oldHeight > 0) {
|
||||||
|
textareaMinHeightRef.current = oldHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
const minHeight = textareaMinHeightRef.current ?? oldHeight
|
||||||
|
|
||||||
|
el.style.transition = 'none'
|
||||||
|
el.style.height = 'auto'
|
||||||
|
const scrollHeight = el.scrollHeight
|
||||||
|
const targetHeight = resolveTextareaTargetHeight({
|
||||||
|
minHeight,
|
||||||
|
maxHeight,
|
||||||
|
scrollHeight,
|
||||||
|
})
|
||||||
|
el.style.height = `${oldHeight}px`
|
||||||
|
el.scrollTop = oldScrollTop
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.scrollTop = oldScrollTop
|
||||||
|
el.style.transition = 'height 200ms ease-out'
|
||||||
|
el.style.height = `${targetHeight}px`
|
||||||
|
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
|
||||||
|
})
|
||||||
|
}, [maxHeightViewportRatio])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
autoResizeTextarea()
|
||||||
|
}, [value, autoResizeTextarea])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full glass-surface-elevated rounded-2xl">
|
||||||
|
<div className="p-6 pb-0">
|
||||||
|
{topRight && (
|
||||||
|
<div className="mb-3 flex items-center justify-end">
|
||||||
|
{topRight}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onValueChange(event.target.value)}
|
||||||
|
onCompositionStart={onCompositionStart}
|
||||||
|
onCompositionEnd={onCompositionEnd}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={minRows}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full resize-none border-none bg-transparent text-base text-[var(--glass-text-primary)] outline-none placeholder:text-[var(--glass-text-tertiary)] custom-scrollbar ${textareaClassName ?? 'p-5 pb-3'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto px-5 pb-4">
|
||||||
|
<div className="flex min-w-max flex-1 items-center gap-2">
|
||||||
|
<div className="w-[118px] flex-shrink-0">
|
||||||
|
<RatioSelector
|
||||||
|
value={videoRatio}
|
||||||
|
onChange={onVideoRatioChange}
|
||||||
|
options={ratioOptions}
|
||||||
|
getUsage={getRatioUsage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-[132px] flex-shrink-0">
|
||||||
|
<StyleSelector
|
||||||
|
value={artStyle}
|
||||||
|
onChange={onArtStyleChange}
|
||||||
|
options={styleOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-[152px] flex-shrink-0">
|
||||||
|
<StylePresetSelector
|
||||||
|
value={stylePresetValue}
|
||||||
|
onChange={onStylePresetChange}
|
||||||
|
options={stylePresetOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex min-w-max items-center gap-2">
|
||||||
|
{secondaryActions}
|
||||||
|
{primaryAction}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<div className="px-6 pb-4">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
src/lib/style-presets.ts
Normal file
16
src/lib/style-presets.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const STYLE_PRESETS = [
|
||||||
|
{
|
||||||
|
value: 'horror-suspense',
|
||||||
|
label: '恐怖悬疑',
|
||||||
|
description: '压迫氛围',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type StylePresetOption = (typeof STYLE_PRESETS)[number]
|
||||||
|
export type StylePresetValue = StylePresetOption['value']
|
||||||
|
|
||||||
|
export const DEFAULT_STYLE_PRESET_VALUE: StylePresetValue = 'horror-suspense'
|
||||||
|
|
||||||
|
export function getStylePresetOption(value: string): StylePresetOption {
|
||||||
|
return STYLE_PRESETS.find((preset) => preset.value === value) ?? STYLE_PRESETS[0]
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const HOME_QUICK_START_MIN_ROWS = 3
|
export const HOME_QUICK_START_MIN_ROWS = 3
|
||||||
|
export const PROJECT_STORY_INPUT_MIN_ROWS = 8
|
||||||
|
|
||||||
interface ResolveTextareaTargetHeightInput {
|
interface ResolveTextareaTargetHeightInput {
|
||||||
minHeight: number
|
minHeight: number
|
||||||
158
tests/unit/components/asset-grid.test.ts
Normal file
158
tests/unit/components/asset-grid.test.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import type { ComponentProps, ReactElement } from 'react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import type { AbstractIntlMessages } from 'next-intl'
|
||||||
|
import { AssetGrid } from '@/app/[locale]/workspace/asset-hub/components/AssetGrid'
|
||||||
|
|
||||||
|
vi.mock('react', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('react')>()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useState: <T,>(initialState: T | (() => T)) => {
|
||||||
|
const resolvedInitialState = typeof initialState === 'function'
|
||||||
|
? (initialState as () => T)()
|
||||||
|
: initialState
|
||||||
|
|
||||||
|
if (resolvedInitialState === 'all') {
|
||||||
|
return actual.useState('location' as T)
|
||||||
|
}
|
||||||
|
|
||||||
|
return actual.useState(resolvedInitialState)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/app/[locale]/workspace/asset-hub/components/CharacterCard', () => ({
|
||||||
|
CharacterCard: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/[locale]/workspace/asset-hub/components/LocationCard', () => ({
|
||||||
|
LocationCard: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/[locale]/workspace/asset-hub/components/VoiceCard', () => ({
|
||||||
|
VoiceCard: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/task/TaskStatusInline', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
assetHub: {
|
||||||
|
allAssets: '所有资产',
|
||||||
|
characters: '角色',
|
||||||
|
locations: '场景',
|
||||||
|
props: '道具',
|
||||||
|
voices: '音色',
|
||||||
|
addAsset: '新建资产',
|
||||||
|
addCharacter: '新建角色',
|
||||||
|
addLocation: '新建场景',
|
||||||
|
addProp: '新建道具',
|
||||||
|
addVoice: '新建音色',
|
||||||
|
downloadAll: '打包下载',
|
||||||
|
downloadAllTitle: '下载全部图片资产',
|
||||||
|
downloading: '打包中...',
|
||||||
|
emptyState: '暂无资产',
|
||||||
|
emptyStateHint: '点击上方按钮添加角色或场景',
|
||||||
|
filteredEmptyHint: '点击新建资产添加资产',
|
||||||
|
pagination: {
|
||||||
|
previous: '上一页',
|
||||||
|
next: '下一页',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const renderWithIntl = (node: ReactElement) => {
|
||||||
|
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||||
|
locale: 'zh',
|
||||||
|
messages: messages as unknown as AbstractIntlMessages,
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
children: node,
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
createElement(NextIntlClientProvider, providerProps),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AssetGrid', () => {
|
||||||
|
it('空状态下使用与资产库一致的 compact 分段控件,并在中间显示新建资产按钮', () => {
|
||||||
|
Reflect.set(globalThis, 'React', React)
|
||||||
|
|
||||||
|
const html = renderWithIntl(
|
||||||
|
createElement(AssetGrid, {
|
||||||
|
assets: [],
|
||||||
|
loading: false,
|
||||||
|
onAddCharacter: () => undefined,
|
||||||
|
onAddLocation: () => undefined,
|
||||||
|
onAddProp: () => undefined,
|
||||||
|
onAddVoice: () => undefined,
|
||||||
|
onDownloadAll: () => undefined,
|
||||||
|
isDownloading: false,
|
||||||
|
selectedFolderId: null,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain('inline-block max-w-full min-w-max')
|
||||||
|
expect(html).toContain('inline-grid grid-flow-col auto-cols-[minmax(96px,max-content)]')
|
||||||
|
expect(html).toContain('justify-center')
|
||||||
|
expect(html).toContain('>新建资产<')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('当前筛选分类没有资产时显示添加提示文案', () => {
|
||||||
|
Reflect.set(globalThis, 'React', React)
|
||||||
|
|
||||||
|
const html = renderWithIntl(
|
||||||
|
createElement(AssetGrid, {
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
id: 'character-1',
|
||||||
|
kind: 'character',
|
||||||
|
family: 'visual',
|
||||||
|
scope: 'project',
|
||||||
|
name: '角色A',
|
||||||
|
folderId: null,
|
||||||
|
capabilities: {
|
||||||
|
canGenerate: true,
|
||||||
|
canSelectRender: false,
|
||||||
|
canRevertRender: false,
|
||||||
|
canModifyRender: false,
|
||||||
|
canUploadRender: false,
|
||||||
|
canBindVoice: false,
|
||||||
|
canCopyFromGlobal: false,
|
||||||
|
},
|
||||||
|
taskRefs: [],
|
||||||
|
taskState: { isRunning: false, lastError: null },
|
||||||
|
variants: [],
|
||||||
|
introduction: null,
|
||||||
|
profileData: null,
|
||||||
|
profileConfirmed: null,
|
||||||
|
profileTaskRefs: [],
|
||||||
|
profileTaskState: { isRunning: false, lastError: null },
|
||||||
|
voice: {
|
||||||
|
voiceType: null,
|
||||||
|
voiceId: null,
|
||||||
|
customVoiceUrl: null,
|
||||||
|
media: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
loading: false,
|
||||||
|
onAddCharacter: () => undefined,
|
||||||
|
onAddLocation: () => undefined,
|
||||||
|
onAddProp: () => undefined,
|
||||||
|
onAddVoice: () => undefined,
|
||||||
|
onDownloadAll: () => undefined,
|
||||||
|
isDownloading: false,
|
||||||
|
selectedFolderId: null,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain('点击新建资产添加资产')
|
||||||
|
})
|
||||||
|
})
|
||||||
107
tests/unit/components/ratio-style-selectors.test.ts
Normal file
107
tests/unit/components/ratio-style-selectors.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { RatioSelector, StylePresetSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
||||||
|
|
||||||
|
const portalMocks = vi.hoisted(() => {
|
||||||
|
return {
|
||||||
|
currentPortalTarget: null as unknown,
|
||||||
|
createPortalMock: vi.fn((node: React.ReactNode, target: unknown) => {
|
||||||
|
const targetLabel = target === portalMocks.currentPortalTarget ? 'body' : 'unknown'
|
||||||
|
return createElement('div', { 'data-portal-target': targetLabel }, node)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('react', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('react')>()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useState: <T,>(initialState: T | (() => T)) => {
|
||||||
|
const resolvedInitialState = typeof initialState === 'function'
|
||||||
|
? (initialState as () => T)()
|
||||||
|
: initialState
|
||||||
|
|
||||||
|
if (resolvedInitialState === false) {
|
||||||
|
return actual.useState(true as T)
|
||||||
|
}
|
||||||
|
|
||||||
|
return actual.useState(resolvedInitialState)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('react-dom', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-dom')>('react-dom')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createPortal: portalMocks.createPortalMock,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/icons', () => ({
|
||||||
|
AppIcon: ({ name, className }: { name: string; className?: string }) =>
|
||||||
|
createElement('span', { 'data-icon': name, className }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('RatioStyleSelectors', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
portalMocks.currentPortalTarget = null
|
||||||
|
Reflect.deleteProperty(globalThis, 'React')
|
||||||
|
Reflect.deleteProperty(globalThis, 'document')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders ratio, style, and style preset dropdown panels through a portal to document.body', () => {
|
||||||
|
const fakeDocument = {
|
||||||
|
body: { nodeName: 'BODY' },
|
||||||
|
}
|
||||||
|
|
||||||
|
Reflect.set(globalThis, 'React', React)
|
||||||
|
portalMocks.currentPortalTarget = fakeDocument.body
|
||||||
|
Reflect.set(globalThis, 'document', fakeDocument)
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement('div', null,
|
||||||
|
createElement(RatioSelector, {
|
||||||
|
value: '9:16',
|
||||||
|
onChange: () => undefined,
|
||||||
|
options: [
|
||||||
|
{ value: '9:16', label: '9:16' },
|
||||||
|
{ value: '16:9', label: '16:9' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createElement(StyleSelector, {
|
||||||
|
value: 'realistic',
|
||||||
|
onChange: () => undefined,
|
||||||
|
options: [
|
||||||
|
{ value: 'realistic', label: '真人风格' },
|
||||||
|
{ value: 'american-comic', label: '美漫风格' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createElement(StylePresetSelector, {
|
||||||
|
value: 'horror-suspense',
|
||||||
|
onChange: () => undefined,
|
||||||
|
options: [
|
||||||
|
{ value: 'horror-suspense', label: '恐怖悬疑', description: '压迫氛围' },
|
||||||
|
{ value: 'dark-noir', label: '暗黑黑色', description: '冷峻低照' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(portalMocks.createPortalMock).toHaveBeenCalledTimes(3)
|
||||||
|
expect(portalMocks.createPortalMock.mock.calls[0]?.[1]).toBe(fakeDocument.body)
|
||||||
|
expect(portalMocks.createPortalMock.mock.calls[1]?.[1]).toBe(fakeDocument.body)
|
||||||
|
expect(portalMocks.createPortalMock.mock.calls[2]?.[1]).toBe(fakeDocument.body)
|
||||||
|
expect(html).toContain('data-portal-target="body"')
|
||||||
|
expect(html).toContain('data-icon="sparklesAlt"')
|
||||||
|
expect(html).toContain('data-icon="clapperboard"')
|
||||||
|
expect(html).toContain('真人风格')
|
||||||
|
expect(html).toContain('16:9')
|
||||||
|
expect(html).toContain('恐怖悬疑')
|
||||||
|
expect(html).toContain('压迫氛围')
|
||||||
|
})
|
||||||
|
})
|
||||||
51
tests/unit/components/story-input-composer.test.ts
Normal file
51
tests/unit/components/story-input-composer.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
|
||||||
|
|
||||||
|
vi.mock('@/components/selectors/RatioStyleSelectors', () => ({
|
||||||
|
RatioSelector: ({
|
||||||
|
getUsage: _getUsage,
|
||||||
|
...props
|
||||||
|
}: Record<string, unknown> & { getUsage?: unknown }) => createElement('div', props, 'RatioSelector'),
|
||||||
|
StyleSelector: (props: Record<string, unknown>) => createElement('div', props, 'StyleSelector'),
|
||||||
|
StylePresetSelector: (props: Record<string, unknown>) => createElement('div', props, 'StylePresetSelector'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('StoryInputComposer', () => {
|
||||||
|
it('renders a shared composer shell with configurable textarea rows and shared controls', () => {
|
||||||
|
Reflect.set(globalThis, 'React', React)
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(StoryInputComposer, {
|
||||||
|
value: '测试内容',
|
||||||
|
onValueChange: () => undefined,
|
||||||
|
placeholder: '请输入内容',
|
||||||
|
minRows: 8,
|
||||||
|
videoRatio: '9:16',
|
||||||
|
onVideoRatioChange: () => undefined,
|
||||||
|
ratioOptions: [{ value: '9:16', label: '9:16' }],
|
||||||
|
artStyle: 'realistic',
|
||||||
|
onArtStyleChange: () => undefined,
|
||||||
|
styleOptions: [{ value: 'realistic', label: '真人风格' }],
|
||||||
|
stylePresetValue: 'horror-suspense',
|
||||||
|
onStylePresetChange: () => undefined,
|
||||||
|
stylePresetOptions: [{ value: 'horror-suspense', label: '恐怖悬疑', description: '压迫氛围' }],
|
||||||
|
topRight: createElement('span', null, '字数:4'),
|
||||||
|
footer: createElement('p', null, '当前配置'),
|
||||||
|
secondaryActions: createElement('button', { type: 'button' }, 'AI 帮我写'),
|
||||||
|
primaryAction: createElement('button', { type: 'button' }, '开始创作'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain('rows="8"')
|
||||||
|
expect(html).toContain('RatioSelector')
|
||||||
|
expect(html).toContain('StyleSelector')
|
||||||
|
expect(html).toContain('StylePresetSelector')
|
||||||
|
expect(html).toContain('字数:4')
|
||||||
|
expect(html).toContain('当前配置')
|
||||||
|
expect(html).toContain('AI 帮我写')
|
||||||
|
expect(html).toContain('开始创作')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,7 +6,7 @@ import HomePage from '@/app/[locale]/home/page'
|
|||||||
import {
|
import {
|
||||||
HOME_QUICK_START_MIN_ROWS,
|
HOME_QUICK_START_MIN_ROWS,
|
||||||
resolveTextareaTargetHeight,
|
resolveTextareaTargetHeight,
|
||||||
} from '@/lib/home/quick-start-textarea'
|
} from '@/lib/ui/textarea-height'
|
||||||
|
|
||||||
vi.mock('next-auth/react', () => ({
|
vi.mock('next-auth/react', () => ({
|
||||||
useSession: () => ({
|
useSession: () => ({
|
||||||
@@ -23,17 +23,30 @@ vi.mock('@/components/Navbar', () => ({
|
|||||||
default: () => createElement('nav', null, 'Navbar'),
|
default: () => createElement('nav', null, 'Navbar'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/story-input/StoryInputComposer', () => ({
|
||||||
|
default: ({
|
||||||
|
minRows,
|
||||||
|
primaryAction,
|
||||||
|
secondaryActions,
|
||||||
|
}: {
|
||||||
|
minRows: number
|
||||||
|
primaryAction: React.ReactNode
|
||||||
|
secondaryActions?: React.ReactNode
|
||||||
|
}) => createElement(
|
||||||
|
'section',
|
||||||
|
{ 'data-min-rows': String(minRows) },
|
||||||
|
secondaryActions,
|
||||||
|
primaryAction,
|
||||||
|
'StoryInputComposer',
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/ui/icons', () => ({
|
vi.mock('@/components/ui/icons', () => ({
|
||||||
AppIcon: ({ name, ...props }: { name: string } & Record<string, unknown>) =>
|
AppIcon: ({ name, ...props }: { name: string } & Record<string, unknown>) =>
|
||||||
createElement('span', { ...props, 'data-icon': name }),
|
createElement('span', { ...props, 'data-icon': name }),
|
||||||
IconGradientDefs: (props: Record<string, unknown>) => createElement('span', props),
|
IconGradientDefs: (props: Record<string, unknown>) => createElement('span', props),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/selectors/RatioStyleSelectors', () => ({
|
|
||||||
RatioSelector: (props: Record<string, unknown>) => createElement('div', props, 'RatioSelector'),
|
|
||||||
StyleSelector: (props: Record<string, unknown>) => createElement('div', props, 'StyleSelector'),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/i18n/navigation', () => ({
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
Link: ({
|
Link: ({
|
||||||
href,
|
href,
|
||||||
@@ -88,6 +101,7 @@ describe('HomePage quick-start input', () => {
|
|||||||
const html = renderToStaticMarkup(createElement(HomePage))
|
const html = renderToStaticMarkup(createElement(HomePage))
|
||||||
|
|
||||||
expect(HOME_QUICK_START_MIN_ROWS).toBe(3)
|
expect(HOME_QUICK_START_MIN_ROWS).toBe(3)
|
||||||
expect(html).toContain('rows="3"')
|
expect(html).toContain('StoryInputComposer')
|
||||||
|
expect(html).toContain('data-min-rows="3"')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
87
tests/unit/novel-promotion/novel-input-stage.test.ts
Normal file
87
tests/unit/novel-promotion/novel-input-stage.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import NovelInputStage from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/NovelInputStage'
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string, values?: Record<string, string | number>) => {
|
||||||
|
if (values && 'name' in values) {
|
||||||
|
return `${key}:${String(values.name)}`
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/story-input/StoryInputComposer', () => ({
|
||||||
|
default: ({
|
||||||
|
minRows,
|
||||||
|
maxHeightViewportRatio,
|
||||||
|
topRight,
|
||||||
|
footer,
|
||||||
|
secondaryActions,
|
||||||
|
primaryAction,
|
||||||
|
}: {
|
||||||
|
minRows: number
|
||||||
|
maxHeightViewportRatio: number
|
||||||
|
topRight?: React.ReactNode
|
||||||
|
footer?: React.ReactNode
|
||||||
|
secondaryActions?: React.ReactNode
|
||||||
|
primaryAction: React.ReactNode
|
||||||
|
}) => createElement(
|
||||||
|
'section',
|
||||||
|
{
|
||||||
|
'data-min-rows': String(minRows),
|
||||||
|
'data-max-height-ratio': String(maxHeightViewportRatio),
|
||||||
|
},
|
||||||
|
topRight,
|
||||||
|
footer,
|
||||||
|
secondaryActions,
|
||||||
|
primaryAction,
|
||||||
|
'StoryInputComposer',
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/task/TaskStatusInline', () => ({
|
||||||
|
default: () => createElement('span', null, 'TaskStatusInline'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/home/AiWriteModal', () => ({
|
||||||
|
default: () => createElement('div', null, 'AiWriteModal'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-fetch', () => ({
|
||||||
|
apiFetch: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/home/ai-story-expand', () => ({
|
||||||
|
expandHomeStory: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/icons', () => ({
|
||||||
|
AppIcon: ({ name, ...props }: { name: string } & Record<string, unknown>) =>
|
||||||
|
createElement('span', { ...props, 'data-icon': name }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('NovelInputStage', () => {
|
||||||
|
it('uses the shared composer with a taller adaptive baseline in story mode', () => {
|
||||||
|
Reflect.set(globalThis, 'React', React)
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(NovelInputStage, {
|
||||||
|
novelText: '',
|
||||||
|
episodeName: '剧集 1',
|
||||||
|
onNovelTextChange: () => undefined,
|
||||||
|
onNext: () => undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain('StoryInputComposer')
|
||||||
|
expect(html).toContain('data-min-rows="8"')
|
||||||
|
expect(html).toContain('data-max-height-ratio="0.5"')
|
||||||
|
expect(html).toContain('aiWrite.trigger')
|
||||||
|
expect(html).toContain('AiWriteModal')
|
||||||
|
expect(html).not.toContain('storyInput.wordCount 0')
|
||||||
|
expect(html).not.toContain('storyInput.currentConfigSummary')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user