feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('')
|
||||
}
|
||||
|
||||
124
src/components/story-input/LongTextDetectionPrompt.tsx
Normal file
124
src/components/story-input/LongTextDetectionPrompt.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user