ManimCat-show / frontend /src /components /InputForm.tsx
Bin29's picture
Add bilingual docs and frontend i18n
a356243
Raw
History Blame Contribute Delete
6.49 kB
// 输入表单组件 - MD3 风格
import { useCallback, useEffect, useRef, useState } from 'react';
import type { OutputMode, Quality, ReferenceImage } from '../types/api';
import { loadSettings } from '../lib/settings';
import { FormToolbar } from './input-form/form-toolbar';
import { ReferenceImageList } from './input-form/reference-image-list';
import { useReferenceImages } from './input-form/use-reference-images';
import { useI18n } from '../i18n';
interface InputFormProps {
onSubmit: (data: {
concept: string;
quality: Quality;
outputMode: OutputMode;
referenceImages?: ReferenceImage[];
}) => void;
loading: boolean;
}
export function InputForm({ onSubmit, loading }: InputFormProps) {
const { t } = useI18n();
const [concept, setConcept] = useState('');
const [error, setError] = useState<string | null>(null);
const [quality, setQuality] = useState<Quality>(loadSettings().video.quality);
const [outputMode, setOutputMode] = useState<OutputMode>('video');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const {
images,
imageError,
isDragging,
fileInputRef,
addImages,
removeImage,
handleDrop,
handleDragOver,
handleDragEnter,
handleDragLeave,
} = useReferenceImages();
const handleSubmit = useCallback(() => {
if (concept.trim().length < 5) {
setError(t('form.error.minLength'));
textareaRef.current?.focus();
return;
}
setError(null);
onSubmit({
concept: concept.trim(),
quality,
outputMode,
referenceImages: images.length > 0 ? images : undefined,
});
}, [concept, quality, outputMode, images, onSubmit, t]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'Enter' && !loading) {
e.preventDefault();
handleSubmit();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [loading, handleSubmit]);
useEffect(() => {
if (concept.length > 0 && concept.length < 5) {
setError(t('form.error.minLengthShort'));
} else {
setError(null);
}
}, [concept, t]);
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleSubmit();
};
return (
<div className="w-full max-w-2xl mx-auto">
<form onSubmit={handleFormSubmit} className="space-y-6">
<div
className={`relative transition-all duration-200 ${isDragging ? 'scale-[1.02]' : ''}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
>
<label
htmlFor="concept"
className={`absolute left-4 -top-2.5 px-2 bg-bg-primary text-xs font-medium transition-all z-10 ${
isDragging ? 'text-accent' : error ? 'text-red-500' : 'text-text-secondary'
}`}
>
{isDragging ? t('form.label.dragging') : error ? error : t('form.label.default')}
</label>
<textarea
ref={textareaRef}
id="concept"
name="concept"
rows={4}
placeholder={t('form.placeholder')}
disabled={loading}
value={concept}
onChange={(e) => setConcept(e.target.value)}
className={`w-full px-4 py-4 bg-bg-secondary/50 rounded-2xl text-text-primary placeholder-text-secondary/40 focus:outline-none focus:ring-2 transition-all resize-none ${
isDragging
? 'ring-2 ring-accent/50 bg-accent/5 border-2 border-dashed border-accent/30'
: error
? 'focus:ring-red-500/20 bg-red-50/50 dark:bg-red-900/10'
: 'focus:ring-accent/20 focus:bg-bg-secondary/70'
}`}
/>
</div>
<FormToolbar
loading={loading}
quality={quality}
outputMode={outputMode}
imageCount={images.length}
fileInputRef={fileInputRef}
onChangeQuality={setQuality}
onChangeOutputMode={setOutputMode}
onUploadFiles={addImages}
/>
<ReferenceImageList images={images} loading={loading} onRemove={removeImage} />
{imageError && <p className="text-xs text-red-500">{imageError}</p>}
<div className="flex justify-center pt-4">
<button
type="submit"
disabled={loading || concept.trim().length < 5}
className="group relative px-12 py-3.5 bg-accent hover:bg-accent-hover text-white font-medium rounded-full shadow-lg shadow-accent/25 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed hover:shadow-xl hover:shadow-accent/35 active:scale-[0.97] overflow-hidden"
>
<span className="relative z-10 flex items-center gap-2">
{loading ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{t('form.submitting')}
</>
) : (
<>
{outputMode === 'image' ? t('form.submit.image') : t('form.submit.video')}
<svg className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</>
)}
</span>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:animate-shimmer" />
</button>
</div>
<p className="text-center text-xs text-text-secondary/50">
{t('form.shortcutPrefix')} <kbd className="px-1.5 py-0.5 bg-bg-secondary/50 rounded text-[10px]">Ctrl</kbd> + <kbd className="px-1.5 py-0.5 bg-bg-secondary/50 rounded text-[10px]">Enter</kbd> {t('form.shortcutSuffix')}
</p>
</form>
</div>
);
}