style: polish UI and improve UX

This commit is contained in:
saturn
2026-03-28 18:58:21 +08:00
parent ca5d8a58f7
commit c3e74c228a
19 changed files with 1182 additions and 267 deletions

View 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">&gt;_</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>
)
}

View File

@@ -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'
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 }) {
const [w, h] = ratio.split(':').map(Number)
@@ -38,38 +109,43 @@ export function RatioSelector({
getUsage?: (ratio: string) => string
}) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const { triggerRef, panelRef, panelStyle } = useFloatingDropdown(isOpen, 300)
useEffect(() => {
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)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
}, [isOpen, panelRef, triggerRef])
const selectedOption = options.find((o) => o.value === value)
return (
<div className="relative" ref={dropdownRef}>
<>
<button
ref={triggerRef}
type="button"
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 />
<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>
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
{isOpen && typeof document !== 'undefined' && createPortal(
<div
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar"
style={{ minWidth: '300px' }}
ref={panelRef}
className="glass-surface-modal z-[9999] p-3 overflow-y-auto custom-scrollbar"
style={panelStyle}
>
<div className="grid grid-cols-5 gap-2">
{options.map((option) => {
@@ -98,9 +174,10 @@ export function RatioSelector({
)
})}
</div>
</div>
</div>,
document.body,
)}
</div>
</>
)
}
@@ -114,33 +191,44 @@ export function StyleSelector({
options: { value: string; label: string; recommended?: boolean }[]
}) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const { triggerRef, panelRef, panelStyle } = useFloatingDropdown(isOpen, 320)
useEffect(() => {
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)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
}, [isOpen, panelRef, triggerRef])
const selectedOption = options.find((o) => o.value === value) || options[0]
return (
<div className="relative" ref={dropdownRef}>
<>
<button
ref={triggerRef}
type="button"
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' : ''}`} />
</button>
{isOpen && (
<div className="glass-surface-modal absolute z-50 mt-1 left-0 p-3" style={{ minWidth: '320px' }}>
{isOpen && typeof document !== 'undefined' && createPortal(
<div
ref={panelRef}
className="glass-surface-modal z-[9999] p-3"
style={panelStyle}
>
<div className="grid grid-cols-2 gap-2">
{options.map((option) => {
const isSelected = value === option.value
@@ -165,8 +253,125 @@ export function StyleSelector({
)
})}
</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>
)
}

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