返回 JoyAI-Echo
FirstFrameUploader.tsx
根目录 / echo_longvideo / Director_Agent / webui / src / components / thread / FirstFrameUploader.tsx
1 import { useCallback, useRef, useState } from "react";
2 import { Loader2, Plus, X } from "lucide-react";
3 import { useTranslation } from "react-i18next";
4
5 import { ImageLightbox } from "@/components/ImageLightbox";
6 import { cn } from "@/lib/utils";
7 import type { FirstFrameData } from "@/hooks/useFirstFrameImage";
8
9 const ACCEPT_ATTR = "image/png,image/jpeg,image/webp";
10
11 interface FirstFrameUploaderProps {
12 value: FirstFrameData | null;
13 cropping?: boolean;
14 disabled?: boolean;
15 /** When false, hide the remove control (e.g. locked after generate). */
16 clearable?: boolean;
17 locked?: boolean;
18 onLockedAttempt?: () => void;
19 onPickFile: (file: File) => void;
20 onClear: () => void;
21 className?: string;
22 }
23
24 /** Dashed “+” tile for short-video first-frame reference image (hero only). */
25 export function FirstFrameUploader({
26 value,
27 cropping = false,
28 disabled = false,
29 clearable = true,
30 locked = false,
31 onLockedAttempt,
32 onPickFile,
33 onClear,
34 className,
35 }: FirstFrameUploaderProps) {
36 const { t } = useTranslation();
37 const inputRef = useRef<HTMLInputElement>(null);
38 const [lightboxOpen, setLightboxOpen] = useState(false);
39
40 const openPicker = useCallback(() => {
41 if (locked) {
42 onLockedAttempt?.();
43 return;
44 }
45 if (disabled || cropping) return;
46 inputRef.current?.click();
47 }, [cropping, disabled, locked, onLockedAttempt]);
48
49 const onInputChange = useCallback(
50 (event: React.ChangeEvent<HTMLInputElement>) => {
51 const file = event.target.files?.[0];
52 event.target.value = "";
53 if (file) onPickFile(file);
54 },
55 [onPickFile],
56 );
57
58 if (value) {
59 return (
60 <>
61 {/* Outer wrapper stays overflow-visible so the clear badge can hang outside. */}
62 <div className={cn("relative h-16 w-16 shrink-0", className)}>
63 <div
64 className={cn(
65 "relative h-full w-full overflow-hidden rounded-xl",
66 "border border-foreground/10 bg-foreground/[0.03]",
67 )}
68 >
69 <button
70 type="button"
71 onClick={() => setLightboxOpen(true)}
72 className="h-full w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/20"
73 aria-label={t("thread.firstFrame.previewAria")}
74 >
75 <img
76 src={value.previewUrl}
77 alt=""
78 className="h-full w-full object-cover"
79 draggable={false}
80 />
81 </button>
82 {cropping ? (
83 <div className="absolute inset-0 flex items-center justify-center bg-background/50">
84 <Loader2 className="h-4 w-4 animate-spin text-foreground/70" />
85 </div>
86 ) : null}
87 </div>
88 {clearable ? (
89 <button
90 type="button"
91 onClick={(e) => {
92 e.stopPropagation();
93 onClear();
94 }}
95 disabled={disabled}
96 className={cn(
97 "absolute -right-1.5 -top-1.5 z-10 flex h-5 w-5 items-center justify-center",
98 "rounded-full bg-foreground text-background shadow-sm",
99 "hover:bg-foreground/90 focus-visible:outline-none focus-visible:ring-2",
100 "focus-visible:ring-foreground/30 disabled:opacity-50",
101 )}
102 aria-label={t("thread.firstFrame.remove")}
103 >
104 <X className="h-3 w-3" aria-hidden />
105 </button>
106 ) : null}
107 </div>
108 <ImageLightbox
109 images={[{ url: value.previewUrl, name: value.name }]}
110 index={lightboxOpen ? 0 : null}
111 onIndexChange={() => {}}
112 onOpenChange={(open) => {
113 if (!open) setLightboxOpen(false);
114 }}
115 />
116 </>
117 );
118 }
119
120 return (
121 <div className={cn("relative shrink-0", className)}>
122 <input
123 ref={inputRef}
124 type="file"
125 accept={ACCEPT_ATTR}
126 className="sr-only"
127 disabled={disabled || cropping || locked}
128 onChange={onInputChange}
129 />
130 <button
131 type="button"
132 onClick={openPicker}
133 disabled={(disabled || cropping) && !locked}
134 aria-label={t("thread.firstFrame.uploadAria")}
135 className={cn(
136 "flex h-16 w-16 items-center justify-center rounded-xl",
137 "border border-dashed border-foreground/20 bg-foreground/[0.02]",
138 "text-foreground/45 transition-colors",
139 "hover:border-foreground/30 hover:bg-foreground/[0.04] hover:text-foreground/60",
140 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/20",
141 "disabled:cursor-not-allowed disabled:opacity-50",
142 locked && "cursor-not-allowed opacity-50 hover:border-foreground/20 hover:bg-foreground/[0.02]",
143 )}
144 >
145 {cropping ? (
146 <Loader2 className="h-5 w-5 animate-spin" aria-hidden />
147 ) : (
148 <Plus className="h-5 w-5" aria-hidden />
149 )}
150 </button>
151 </div>
152 );
153 }
154
154 lines Plain Text