feat(home): animated typewriter placeholder for the prompt composer
Cycle through localized example prompts with a typing/deleting animation in the home composer placeholder, start the field empty instead of with a default prompt, and style the home editor with larger prompt text. Also rebrand the device-management copy from Lovart to Moteva. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
|||||||
email: "电子邮箱",
|
email: "电子邮箱",
|
||||||
deviceManagement: "设备管理",
|
deviceManagement: "设备管理",
|
||||||
removeAllDevices: "移除全部设备",
|
removeAllDevices: "移除全部设备",
|
||||||
removeAllDevicesDescription: "Lovart 账户仅限个人使用。为防止滥用并确保平台稳定,任务最多可同时在 2 个桌面 Web 会话和 1 个移动 Web 会话中运行。如需团队或多人使用,请购买 Team Plan。",
|
removeAllDevicesDescription: "Moteva 账户仅限个人使用。为防止滥用并确保平台稳定,任务最多可同时在 2 个桌面 Web 会话和 1 个移动 Web 会话中运行。如需团队或多人使用,请购买 Team Plan。",
|
||||||
onlineStatus: "在线状态",
|
onlineStatus: "在线状态",
|
||||||
deviceInfo: "设备信息",
|
deviceInfo: "设备信息",
|
||||||
currentDevice: "当前设备",
|
currentDevice: "当前设备",
|
||||||
@@ -490,7 +490,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
|||||||
email: "Email",
|
email: "Email",
|
||||||
deviceManagement: "Device Management",
|
deviceManagement: "Device Management",
|
||||||
removeAllDevices: "Remove All Devices",
|
removeAllDevices: "Remove All Devices",
|
||||||
removeAllDevicesDescription: "A Lovart account is for personal use only. To prevent abuse and keep the platform stable, tasks can run in up to 2 desktop web sessions and 1 mobile web session at the same time. For team or multi-person use, please purchase Team Plan.",
|
removeAllDevicesDescription: "A Moteva account is for personal use only. To prevent abuse and keep the platform stable, tasks can run in up to 2 desktop web sessions and 1 mobile web session at the same time. For team or multi-person use, please purchase Team Plan.",
|
||||||
onlineStatus: "Online",
|
onlineStatus: "Online",
|
||||||
deviceInfo: "Device Info",
|
deviceInfo: "Device Info",
|
||||||
currentDevice: "Current Device",
|
currentDevice: "Current Device",
|
||||||
|
|||||||
@@ -75,6 +75,41 @@ type HomeImageModel = {
|
|||||||
credits: number;
|
credits: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HomePromptPlaceholderBlueprint = {
|
||||||
|
prefix: string;
|
||||||
|
phrases: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TypewriterPhase = "typing" | "deleting" | "pausing";
|
||||||
|
|
||||||
|
const homePromptPlaceholderBlueprints: Record<Locale, HomePromptPlaceholderBlueprint> = {
|
||||||
|
"zh-CN": {
|
||||||
|
prefix: "让 Moteva ",
|
||||||
|
phrases: [
|
||||||
|
"设计一个令人惊艳的标志",
|
||||||
|
"创建一个温馨的咖啡厅风格菜单海报",
|
||||||
|
"生成一组水彩电影感插画分镜",
|
||||||
|
"为一家日系面包店设计完整品牌视觉",
|
||||||
|
"制作一张高转化的电商产品图"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"en-US": {
|
||||||
|
prefix: "Ask Moteva to ",
|
||||||
|
phrases: [
|
||||||
|
"design a striking logo",
|
||||||
|
"create a warm cafe-style menu poster",
|
||||||
|
"generate a watercolor cinematic storyboard",
|
||||||
|
"build a complete brand identity for a Japanese bakery",
|
||||||
|
"make a high-converting e-commerce product visual"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOME_PLACEHOLDER_TYPE_DELAY_MS = 48;
|
||||||
|
const HOME_PLACEHOLDER_DELETE_DELAY_MS = 24;
|
||||||
|
const HOME_PLACEHOLDER_HOLD_DELAY_MS = 1600;
|
||||||
|
const HOME_PLACEHOLDER_PAUSE_DELAY_MS = 260;
|
||||||
|
|
||||||
const promptModeBlueprints = [
|
const promptModeBlueprints = [
|
||||||
{
|
{
|
||||||
id: "poster",
|
id: "poster",
|
||||||
@@ -519,6 +554,55 @@ function hasPromptText(value: string) {
|
|||||||
return value.replace(/\u00a0/g, " ").trim().length > 0;
|
return value.replace(/\u00a0/g, " ").trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useHomePromptPlaceholder(locale: Locale) {
|
||||||
|
const blueprint = homePromptPlaceholderBlueprints[locale];
|
||||||
|
const [phraseIndex, setPhraseIndex] = useState(0);
|
||||||
|
const [visibleCharacters, setVisibleCharacters] = useState(0);
|
||||||
|
const [phase, setPhase] = useState<TypewriterPhase>("typing");
|
||||||
|
const phrase = blueprint.phrases[phraseIndex] ?? blueprint.phrases[0] ?? "";
|
||||||
|
const characters = useMemo(() => Array.from(phrase), [phrase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPhraseIndex(0);
|
||||||
|
setVisibleCharacters(0);
|
||||||
|
setPhase("typing");
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === "typing") {
|
||||||
|
if (visibleCharacters < characters.length) {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setVisibleCharacters((count) => Math.min(count + 1, characters.length));
|
||||||
|
}, HOME_PLACEHOLDER_TYPE_DELAY_MS);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => setPhase("deleting"), HOME_PLACEHOLDER_HOLD_DELAY_MS);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "deleting") {
|
||||||
|
if (visibleCharacters > 0) {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setVisibleCharacters((count) => Math.max(count - 1, 0));
|
||||||
|
}, HOME_PLACEHOLDER_DELETE_DELAY_MS);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => setPhase("pausing"), HOME_PLACEHOLDER_PAUSE_DELAY_MS);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setPhraseIndex((index) => (index + 1) % blueprint.phrases.length);
|
||||||
|
setPhase("typing");
|
||||||
|
}, HOME_PLACEHOLDER_PAUSE_DELAY_MS);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [blueprint, characters.length, phase, visibleCharacters]);
|
||||||
|
|
||||||
|
return `${blueprint.prefix}${characters.slice(0, visibleCharacters).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
type CreateProjectOptions = {
|
type CreateProjectOptions = {
|
||||||
allowEmptyPrompt?: boolean;
|
allowEmptyPrompt?: boolean;
|
||||||
};
|
};
|
||||||
@@ -535,7 +619,7 @@ export function HomePage() {
|
|||||||
const promptComposerRef = useRef<PromptComposerHandle | null>(null);
|
const promptComposerRef = useRef<PromptComposerHandle | null>(null);
|
||||||
const referenceImagesRef = useRef<UploadedReferenceImage[]>([]);
|
const referenceImagesRef = useRef<UploadedReferenceImage[]>([]);
|
||||||
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
|
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
|
||||||
const [prompt, setPrompt] = useState(() => t("defaultPrompt"));
|
const [prompt, setPrompt] = useState("");
|
||||||
const [referenceImages, setReferenceImages] = useState<UploadedReferenceImage[]>([]);
|
const [referenceImages, setReferenceImages] = useState<UploadedReferenceImage[]>([]);
|
||||||
const [activeModeId, setActiveModeId] = useState("poster");
|
const [activeModeId, setActiveModeId] = useState("poster");
|
||||||
const [activeInspirationCategory, setActiveInspirationCategory] = useState("all");
|
const [activeInspirationCategory, setActiveInspirationCategory] = useState("all");
|
||||||
@@ -571,6 +655,7 @@ export function HomePage() {
|
|||||||
const selectedModel = imageModels.find((model) => model.id === selectedModelId) ?? imageModels[0];
|
const selectedModel = imageModels.find((model) => model.id === selectedModelId) ?? imageModels[0];
|
||||||
const inspirationCategories = useMemo(() => getInspirationCategories(locale), [locale]);
|
const inspirationCategories = useMemo(() => getInspirationCategories(locale), [locale]);
|
||||||
const inspirationCatalog = useMemo(() => getInspirationCatalog(locale), [locale]);
|
const inspirationCatalog = useMemo(() => getInspirationCatalog(locale), [locale]);
|
||||||
|
const promptPlaceholder = useHomePromptPlaceholder(locale);
|
||||||
const activeMode = promptModes.find((mode) => mode.id === activeModeId) ?? promptModes[0];
|
const activeMode = promptModes.find((mode) => mode.id === activeModeId) ?? promptModes[0];
|
||||||
const projects = dashboard?.projects ?? [];
|
const projects = dashboard?.projects ?? [];
|
||||||
const canSubmitPrompt = hasPromptText(prompt);
|
const canSubmitPrompt = hasPromptText(prompt);
|
||||||
@@ -728,8 +813,9 @@ export function HomePage() {
|
|||||||
<PromptComposer
|
<PromptComposer
|
||||||
ref={promptComposerRef}
|
ref={promptComposerRef}
|
||||||
text={prompt}
|
text={prompt}
|
||||||
placeholder={t("promptPlaceholder")}
|
placeholder={promptPlaceholder}
|
||||||
ariaLabel={t("promptPlaceholder")}
|
ariaLabel={t("promptPlaceholder")}
|
||||||
|
className="home-prompt-editor"
|
||||||
removeLabel={t("removeReference")}
|
removeLabel={t("removeReference")}
|
||||||
onTextChange={setPrompt}
|
onTextChange={setPrompt}
|
||||||
onReferencesChange={syncReferenceImages}
|
onReferencesChange={syncReferenceImages}
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ button:disabled {
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-rich-editor.home-prompt-editor {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.prompt-rich-editor[data-empty="true"]::before {
|
.prompt-rich-editor[data-empty="true"]::before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -146,6 +153,10 @@ button:disabled {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-rich-editor.home-prompt-editor[data-empty="true"]::before {
|
||||||
|
color: #b5b7bc;
|
||||||
|
}
|
||||||
|
|
||||||
.prompt-card.has-references .prompt-rich-editor {
|
.prompt-card.has-references .prompt-rich-editor {
|
||||||
min-height: 118px;
|
min-height: 118px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user