feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions

This commit is contained in:
saturn
2026-04-02 17:39:16 +08:00
parent c3e74c228a
commit 9703714b69
153 changed files with 4472 additions and 1088 deletions

View File

@@ -5,7 +5,7 @@ import { AppIcon } from '@/components/ui/icons'
interface ImageGenerationInlineCountButtonProps {
prefix: ReactNode
suffix: ReactNode
suffix?: ReactNode
value: number
options: number[]
onValueChange: (value: number) => void
@@ -13,7 +13,11 @@ interface ImageGenerationInlineCountButtonProps {
disabled?: boolean
actionDisabled?: boolean
selectDisabled?: boolean
showCountControl?: boolean
splitInteractiveZones?: boolean
className?: string
actionClassName?: string
countClassName?: string
selectClassName?: string
labelClassName?: string
ariaLabel: string
@@ -29,7 +33,11 @@ export default function ImageGenerationInlineCountButton({
disabled = false,
actionDisabled,
selectDisabled,
showCountControl = true,
splitInteractiveZones = false,
className = '',
actionClassName = '',
countClassName = '',
selectClassName = '',
labelClassName = '',
ariaLabel,
@@ -42,6 +50,68 @@ export default function ImageGenerationInlineCountButton({
const selectStateClassName = isSelectDisabled
? 'pointer-events-none opacity-70'
: 'cursor-pointer'
const resolvedActionClassName = (actionClassName || className).trim()
if (!showCountControl) {
return (
<button
type="button"
onClick={() => {
if (isActionDisabled) return
onClick()
}}
disabled={isActionDisabled}
aria-label={ariaLabel}
className={`${resolvedActionClassName} ${rootStateClassName}`.trim()}
>
<span className={`${labelClassName} inline-flex items-center gap-1 whitespace-nowrap`.trim()}>{prefix}</span>
</button>
)
}
if (splitInteractiveZones) {
return (
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => {
if (isActionDisabled) return
onClick()
}}
disabled={isActionDisabled}
aria-label={ariaLabel}
className={`${resolvedActionClassName} ${rootStateClassName}`.trim()}
>
<span className={`${labelClassName} inline-flex items-center gap-1 whitespace-nowrap`.trim()}>{prefix}</span>
</button>
<span
className={`group relative inline-flex h-6 items-center gap-1 rounded-md px-1.5 transition-colors ${
isSelectDisabled ? '' : 'hover:bg-white/12 focus-within:bg-white/14'
} ${countClassName}`.trim()}
>
<select
value={String(value)}
onChange={(event) => onValueChange(Number(event.target.value))}
aria-label={ariaLabel}
disabled={isSelectDisabled}
className={`${selectClassName} ${selectStateClassName}`.trim()}
>
{options.map((option) => (
<option key={option} value={option} className="text-black">
{option}
</option>
))}
</select>
<span className="pointer-events-none absolute inset-y-0 right-1 flex items-center text-current opacity-85 transition-colors group-hover:opacity-100 group-focus-within:opacity-100">
<AppIcon name="chevronDown" className="h-3 w-3" />
</span>
{suffix ? (
<span className={`${labelClassName} whitespace-nowrap pr-4`.trim()}>{suffix}</span>
) : null}
</span>
</div>
)
}
return (
<div
@@ -61,10 +131,10 @@ export default function ImageGenerationInlineCountButton({
aria-disabled={isActionDisabled}
className={`${className} ${rootStateClassName}`.trim()}
>
<span className={labelClassName}>{prefix}</span>
<span className={`${labelClassName} inline-flex shrink-0 items-center whitespace-nowrap leading-none`.trim()}>{prefix}</span>
<span
className={`group relative inline-flex items-center rounded-md px-1.5 py-0.5 transition-colors ${
isSelectDisabled ? '' : 'hover:bg-white/12 focus-within:bg-white/14'
className={`group relative inline-flex h-8 shrink-0 items-center rounded-full bg-white/12 px-2 transition-colors ${
isSelectDisabled ? '' : 'hover:bg-white/16 focus-within:bg-white/18'
}`}
onClick={(event: MouseEvent<HTMLSpanElement>) => event.stopPropagation()}
>
@@ -81,11 +151,11 @@ export default function ImageGenerationInlineCountButton({
</option>
))}
</select>
<span className="pointer-events-none absolute inset-y-0 right-1 flex items-center text-current opacity-85 transition-colors group-hover:opacity-100 group-focus-within:opacity-100">
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-current opacity-85 transition-colors group-hover:opacity-100 group-focus-within:opacity-100">
<AppIcon name="chevronDown" className="h-3 w-3" />
</span>
</span>
<span className={labelClassName}>{suffix}</span>
<span className={`${labelClassName} inline-flex shrink-0 items-center whitespace-nowrap leading-none`.trim()}>{suffix}</span>
</div>
)
}

View File

@@ -19,6 +19,7 @@ import {
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
export interface LocationCreationModalProps {
mode: 'asset-hub' | 'project'
@@ -63,6 +64,7 @@ export function LocationCreationModal({
const [description, setDescription] = useState('')
const [aiInstruction, setAiInstruction] = useState('')
const [artStyle, setArtStyle] = useState('american-comic')
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [isAiDesigning, setIsAiDesigning] = useState(false)
@@ -119,6 +121,7 @@ export function LocationCreationModal({
? await aiDesignAssetHubLocation.mutateAsync(aiInstruction)
: await aiCreateProjectLocation.mutateAsync({ userInstruction: aiInstruction })
setDescription(data.prompt || '')
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
setAiInstruction('')
} catch (error: unknown) {
if (getErrorStatus(error) === 402) {
@@ -168,12 +171,14 @@ export function LocationCreationModal({
summary: body.description,
artStyle: body.artStyle,
folderId: body.folderId ?? null,
availableSlots,
})
} else {
await createProjectLocation.mutateAsync({
name: body.name,
description: body.description,
artStyle: body.artStyle,
availableSlots,
})
}
@@ -203,6 +208,7 @@ export function LocationCreationModal({
artStyle,
folderId: folderId ?? null,
count: locationGenerationCount,
availableSlots,
}) as CreatedLocationResponse
const createdLocationId = result.location?.id
if (!createdLocationId) {
@@ -219,6 +225,7 @@ export function LocationCreationModal({
description: description.trim(),
artStyle,
count: locationGenerationCount,
availableSlots,
}) as CreatedLocationResponse
const createdLocationId = result.location?.id
if (!createdLocationId) {

View File

@@ -14,6 +14,7 @@ import {
useUpdateProjectLocationDescription,
useUpdateProjectLocationName,
} from '@/lib/query/hooks'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
export interface LocationEditModalProps {
mode: 'asset-hub' | 'project'
@@ -56,6 +57,7 @@ export function LocationEditModal({
const [editingName, setEditingName] = useState(locationName)
const [editingDescription, setEditingDescription] = useState(description || summary || '')
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
const [aiModifyInstruction, setAiModifyInstruction] = useState('')
const [isAiModifying, setIsAiModifying] = useState(false)
const [isSaving, setIsSaving] = useState(false)
@@ -113,6 +115,7 @@ export function LocationEditModal({
await updateAssetHubSummary.mutateAsync({
locationId,
summary: editingDescription,
availableSlots,
})
return
}
@@ -121,6 +124,7 @@ export function LocationEditModal({
locationId,
imageIndex: resolvedImageIndex,
description: editingDescription,
availableSlots,
})
}
@@ -139,6 +143,7 @@ export function LocationEditModal({
})
if (data?.modifiedDescription) {
setEditingDescription(data.modifiedDescription)
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
onUpdate?.(data.modifiedDescription)
setAiModifyInstruction('')
}
@@ -154,6 +159,7 @@ export function LocationEditModal({
const nextDescription = data?.modifiedDescription || data?.prompt || ''
if (nextDescription) {
setEditingDescription(nextDescription)
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
onUpdate?.(nextDescription)
setAiModifyInstruction('')
}

View File

@@ -0,0 +1,124 @@
'use client'
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { AppIcon } from '@/components/ui/icons'
interface LongTextDetectionPromptCopy {
title: string
description: string
strongRecommend: string
smartSplitLabel: string
smartSplitBadge: string
continueLabel: string
continueHint: string
}
interface LongTextDetectionPromptProps {
open: boolean
copy: LongTextDetectionPromptCopy
onClose: () => void
onSmartSplit: () => void
onContinue: () => void
}
export default function LongTextDetectionPrompt({
open,
copy,
onClose,
onSmartSplit,
onContinue,
}: LongTextDetectionPromptProps) {
useEffect(() => {
if (!open) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, open])
if (!open || typeof document === 'undefined') {
return null
}
return createPortal(
<div
className="fixed inset-0 z-[120] flex items-center justify-center glass-overlay p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose()
}
}}
>
<div className="glass-surface-modal w-full max-w-lg rounded-2xl border border-[var(--glass-stroke-base)] p-6 shadow-[0_20px_80px_-32px_rgba(15,23,42,0.45)]">
<div className="space-y-5">
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(139,92,246,0.15))' }}
>
<AppIcon name="sparkles" className="h-5 w-5 text-[#7c3aed]" />
</div>
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">
{copy.title}
</h3>
</div>
<p className="text-sm leading-relaxed text-[var(--glass-text-secondary)]">
{copy.description}
</p>
<div
className="rounded-xl p-4 text-sm leading-relaxed"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(139,92,246,0.08))' }}
>
<p
className="font-semibold"
style={{
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{copy.strongRecommend}
</p>
</div>
<div className="flex flex-col gap-3 pt-1">
<button
type="button"
onClick={onSmartSplit}
className="flex w-full items-center justify-center gap-2 rounded-xl py-3.5 text-base font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ background: 'linear-gradient(135deg, #3b82f6, #7c3aed)' }}
>
<AppIcon name="sparkles" className="h-5 w-5" />
<span>{copy.smartSplitLabel}</span>
<span className="rounded-full bg-white/20 px-2 py-0.5 text-xs">
{copy.smartSplitBadge}
</span>
</button>
<button
type="button"
onClick={onContinue}
className="w-full py-2.5 text-sm text-[var(--glass-text-tertiary)] transition-colors hover:text-[var(--glass-text-secondary)]"
>
{copy.continueLabel}
<span className="ml-1 text-xs opacity-60">
- {copy.continueHint}
</span>
</button>
</div>
</div>
</div>
</div>,
document.body,
)
}

View File

@@ -109,7 +109,7 @@ export default function StoryInputComposer({
return (
<div className="relative w-full glass-surface-elevated rounded-2xl">
<div className="p-6 pb-0">
<div className="p-6 pb-4">
{topRight && (
<div className="mb-3 flex items-center justify-end">
{topRight}

View File

@@ -133,7 +133,7 @@ export function CapsuleNav({ items, activeId, onItemClick, projectId, episodeId
}
return (
<nav className="fixed top-20 left-1/2 -translate-x-1/2 z-50 animate-fadeInDown">
<nav className="fixed top-20 left-1/2 -translate-x-1/2 z-40 animate-fadeInDown">
<div
className="flex rounded-full px-2 py-1"
style={{
@@ -215,7 +215,7 @@ export function EpisodeSelector({
if (!currentEp) return null
return (
<div className="fixed top-20 left-6 z-[60]" ref={menuRef}>
<div className="fixed top-20 left-6 z-40" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="glass-btn-base glass-btn-secondary flex items-center gap-3 px-4 py-3 transition-all group"