feat(home): align release hero with main
This commit is contained in:
parent
74b13bde71
commit
ce4e8bf7ae
117
src/app/api/admin/homepage-video/route.ts
Normal file
117
src/app/api/admin/homepage-video/route.ts
Normal file
@ -0,0 +1,117 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
|
||||
import {
|
||||
getAccountSession,
|
||||
userHasPermission,
|
||||
userHasRole,
|
||||
userHasRoleOrPermission,
|
||||
} from "@server/account/session";
|
||||
import type { AccountUserRole } from "@server/account/session";
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
|
||||
|
||||
const READ_ROLES: AccountUserRole[] = ["admin", "operator"];
|
||||
const WRITE_ROLES: AccountUserRole[] = ["admin"];
|
||||
const READ_PERMISSIONS = ["admin.settings.read"];
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
async function proxyAccountRequest(
|
||||
request: NextRequest,
|
||||
endpoint: string,
|
||||
method: string,
|
||||
token: string,
|
||||
) {
|
||||
const headers = new Headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
});
|
||||
|
||||
let body: string | undefined;
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
body = await request.text();
|
||||
headers.set(
|
||||
"Content-Type",
|
||||
request.headers.get("content-type") ?? "application/json",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "invalid_response" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getAccountSession(request);
|
||||
const user = session.user;
|
||||
|
||||
if (!user || !session.token) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "unauthenticated" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await userHasRoleOrPermission(user, READ_ROLES, READ_PERMISSIONS))) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "forbidden" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
return proxyAccountRequest(
|
||||
request,
|
||||
`${ACCOUNT_API_BASE}/admin/homepage-video`,
|
||||
"GET",
|
||||
session.token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const session = await getAccountSession(request);
|
||||
const user = session.user;
|
||||
|
||||
if (!user || !session.token) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "unauthenticated" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
(await userHasRole(user, WRITE_ROLES)) ||
|
||||
(await userHasPermission(user, ["admin.settings.write"]))
|
||||
)
|
||||
) {
|
||||
return NextResponse.json<ErrorPayload>(
|
||||
{ error: "forbidden" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
return proxyAccountRequest(
|
||||
request,
|
||||
`${ACCOUNT_API_BASE}/admin/homepage-video`,
|
||||
"PUT",
|
||||
session.token,
|
||||
);
|
||||
}
|
||||
40
src/app/api/homepage-video/route.ts
Normal file
40
src/app/api/homepage-video/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from "@/server/serviceConfig";
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const headers = new Headers({
|
||||
Accept: "application/json",
|
||||
});
|
||||
|
||||
const requestHost =
|
||||
request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
||||
if (requestHost?.trim()) {
|
||||
headers.set("X-Forwarded-Host", requestHost.trim());
|
||||
}
|
||||
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/homepage-video`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (payload === null) {
|
||||
return NextResponse.json({ error: "invalid_response" }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
} catch (error) {
|
||||
console.error("homepage video proxy failed", error);
|
||||
return NextResponse.json(
|
||||
{ error: "account_service_unreachable" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
488
src/app/page.tsx
488
src/app/page.tsx
@ -2,17 +2,16 @@
|
||||
|
||||
export const dynamic = "error";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AppWindow,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Command,
|
||||
Layers,
|
||||
Link,
|
||||
Lock,
|
||||
MousePointerClick,
|
||||
Play,
|
||||
PlusCircle,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
@ -20,16 +19,19 @@ import {
|
||||
} from "lucide-react";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { OpenClawAssistantPane } from "@/components/openclaw/OpenClawAssistantPane";
|
||||
import type { IntegrationDefaults } from "@/lib/openclaw/types";
|
||||
import Footer from "../components/Footer";
|
||||
import UnifiedNavigation from "../components/UnifiedNavigation";
|
||||
import { useLanguage } from "../i18n/LanguageProvider";
|
||||
import { translations } from "../i18n/translations";
|
||||
import {
|
||||
heroVideoMedia,
|
||||
type HeroVideoMedia,
|
||||
} from "../lib/home/heroVideoMedia";
|
||||
DEFAULT_HOMEPAGE_VIDEO_SETTINGS,
|
||||
resolveHomepageVideoPresentation,
|
||||
type HomepageVideoPresentation,
|
||||
type ResolvedHomepageVideoResponse,
|
||||
} from "../lib/home/homepageVideo";
|
||||
import { useMoltbotStore } from "../lib/moltbotStore";
|
||||
import { useUserStore } from "../lib/userStore";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const HOME_SECTION_CLASS =
|
||||
@ -38,6 +40,18 @@ const HOME_SECTION_LABEL_CLASS =
|
||||
"text-[0.68rem] font-semibold uppercase tracking-[0.26em] text-text-subtle";
|
||||
const HOME_LIST_CARD_CLASS =
|
||||
"rounded-[1.5rem] border border-slate-900/10 bg-[#fcfbf8] transition duration-200";
|
||||
const EMPTY_ASSISTANT_DEFAULTS: IntegrationDefaults = {
|
||||
openclawUrl: "",
|
||||
openclawOrigin: "",
|
||||
openclawTokenConfigured: false,
|
||||
vaultUrl: "",
|
||||
vaultNamespace: "",
|
||||
vaultTokenConfigured: false,
|
||||
vaultSecretPath: "",
|
||||
vaultSecretKey: "",
|
||||
apisixUrl: "",
|
||||
apisixTokenConfigured: false,
|
||||
};
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
"Global Acceleration Network": Link,
|
||||
@ -71,6 +85,33 @@ const iconMap: Record<string, any> = {
|
||||
|
||||
const getIcon = (key: string, fallback: any) => iconMap[key] || fallback;
|
||||
|
||||
async function jsonFetcher<T>(
|
||||
input: RequestInfo,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(init?.headers instanceof Headers
|
||||
? Object.fromEntries(init.headers.entries())
|
||||
: init?.headers),
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
throw new Error(payload.error ?? payload.message ?? "请求失败");
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { mode, isOpen } = useMoltbotStore();
|
||||
|
||||
@ -92,7 +133,6 @@ export default function HomePage() {
|
||||
<div className="relative mx-auto max-w-6xl px-4 pb-16 sm:px-6 sm:pb-20">
|
||||
<main className="relative space-y-6 pt-6 sm:space-y-8 sm:pt-10">
|
||||
<HeroSection />
|
||||
<NextStepsSection />
|
||||
<StatsSection />
|
||||
<ShortcutsSection />
|
||||
</main>
|
||||
@ -107,10 +147,69 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
export function HeroSection() {
|
||||
const { user } = useUserStore();
|
||||
const { language } = useLanguage();
|
||||
const isChinese = language === "zh";
|
||||
const [promptSeed, setPromptSeed] = useState("");
|
||||
const [promptSeedKey, setPromptSeedKey] = useState(0);
|
||||
const t = translations[language].marketing.home;
|
||||
const assistantDefaultsSWR = useSWR<IntegrationDefaults>(
|
||||
"/api/integrations/defaults",
|
||||
jsonFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
const homepageVideoSWR = useSWR<ResolvedHomepageVideoResponse>(
|
||||
"/api/homepage-video",
|
||||
jsonFetcher,
|
||||
{
|
||||
fallbackData: {
|
||||
resolved: DEFAULT_HOMEPAGE_VIDEO_SETTINGS.defaultEntry,
|
||||
},
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
const entry =
|
||||
homepageVideoSWR.data?.resolved ?? DEFAULT_HOMEPAGE_VIDEO_SETTINGS.defaultEntry;
|
||||
const presentation = resolveHomepageVideoPresentation(entry);
|
||||
|
||||
const heroCopy = isChinese
|
||||
? {
|
||||
eyebrow: "AI Native Workspace",
|
||||
title: "直接说出你的需求,剩下的交给 AI",
|
||||
subtitle: "从想法到上线,AI 自动完成构建、部署与优化。",
|
||||
demoLabel: "产品演示",
|
||||
demoHint:
|
||||
"这里展示当前域名对应的产品演示链接。主站默认走 YouTube,中国站可切到 Bilibili,也可以继续按域名覆盖。",
|
||||
startTitle: t.nextSteps.title,
|
||||
startHint: "保留原有 onboarding 内容,但改成更轻、更整齐的起步列表。",
|
||||
itemHint: "点击后填入右侧输入框,不会自动发送。",
|
||||
examples: [
|
||||
"帮我构建一个 SaaS 应用",
|
||||
"分析这个报错并给出修复建议",
|
||||
"生成一个 AI agent workflow",
|
||||
"帮我设计一个控制台首页",
|
||||
],
|
||||
}
|
||||
: {
|
||||
eyebrow: "AI Native Workspace",
|
||||
title: "Describe what you need. Let AI handle the rest.",
|
||||
subtitle:
|
||||
"From idea to launch, AI can assemble, deploy, and optimize the work.",
|
||||
demoLabel: "Product demo",
|
||||
demoHint:
|
||||
"This section resolves the product demo for the current host. The default can use YouTube while regional hosts override it.",
|
||||
startTitle: t.nextSteps.title,
|
||||
startHint:
|
||||
"Keep the same onboarding content, in a lighter and calmer starting list.",
|
||||
itemHint: "Click to fill the composer on the right. It will not auto-submit.",
|
||||
examples: [
|
||||
"Help me build a SaaS app",
|
||||
"Analyze this error and suggest a fix",
|
||||
"Generate an AI agent workflow",
|
||||
"Design a console homepage",
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-[2.75rem] border border-slate-900/10 bg-[linear-gradient(180deg,#ffffff,#faf7f2)] p-6 shadow-[0_24px_56px_rgba(15,23,42,0.05)] sm:p-8 lg:p-10">
|
||||
@ -122,133 +221,139 @@ export function HeroSection() {
|
||||
</div>
|
||||
|
||||
<div className="relative grid gap-8 lg:grid-cols-[0.96fr_1.04fr] lg:gap-12">
|
||||
<div className="flex flex-col justify-between gap-8">
|
||||
<div className="flex flex-col gap-6 pt-2">
|
||||
<div className="overflow-hidden rounded-[2rem] border border-slate-900/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(243,246,251,0.96))] shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
|
||||
<div className="border-b border-slate-900/10 px-5 py-4 sm:px-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className={HOME_SECTION_LABEL_CLASS}>{heroCopy.demoLabel}</p>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-text-muted">
|
||||
{heroCopy.demoHint}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden rounded-full border border-slate-900/10 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-600 sm:inline-flex">
|
||||
{entry.domain?.trim()
|
||||
? `${isChinese ? "当前域名" : "Host"}: ${entry.domain}`
|
||||
: isChinese
|
||||
? "默认主站配置"
|
||||
: "Default site config"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 sm:p-5">
|
||||
<DemoVideoSurface presentation={presentation} isChinese={isChinese} />
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-500">
|
||||
<a
|
||||
href={entry.videoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center rounded-full border border-slate-900/10 bg-white px-3 py-1.5 font-semibold text-slate-600 transition hover:border-slate-300 hover:text-slate-900"
|
||||
>
|
||||
{isChinese ? "打开原始链接" : "Open source link"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{t.hero.eyebrow ? (
|
||||
<p className={HOME_SECTION_LABEL_CLASS}>{t.hero.eyebrow}</p>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<p className={HOME_SECTION_LABEL_CLASS}>{heroCopy.eyebrow}</p>
|
||||
<h1
|
||||
className={cn(
|
||||
"max-w-[11ch] leading-[0.88] text-heading",
|
||||
"max-w-[10ch] leading-[0.94] text-heading",
|
||||
isChinese
|
||||
? "text-[2.85rem] font-semibold tracking-[-0.08em] sm:text-[3.4rem] lg:text-[4.5rem]"
|
||||
: "editorial-display text-[3.05rem] tracking-[-0.06em] sm:text-[3.6rem] lg:text-[4.8rem]",
|
||||
? "text-[3.05rem] font-semibold tracking-[-0.055em] sm:text-[3.6rem] lg:text-[4.2rem]"
|
||||
: "editorial-display text-[3rem] tracking-[-0.05em] sm:text-[3.5rem] lg:text-[4.3rem]",
|
||||
)}
|
||||
>
|
||||
{t.hero.title}
|
||||
{heroCopy.title}
|
||||
</h1>
|
||||
<p className="max-w-xl text-[1rem] leading-8 text-text-muted sm:text-[1.05rem]">
|
||||
{t.hero.subtitle}
|
||||
<p className="max-w-xl text-[1rem] leading-8 text-text-muted sm:text-[1.08rem]">
|
||||
{heroCopy.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{user ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-success/25 bg-success/10 px-4 py-2 text-sm font-semibold text-success">
|
||||
<div className="h-2 w-2 rounded-full bg-success animate-pulse" />
|
||||
{t.signedIn.replace("{{username}}", user.username)}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-950 text-white">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-900">
|
||||
{heroCopy.startTitle}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">{heroCopy.startHint}</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-slate-950 px-6 py-3 text-sm font-semibold text-white transition hover:bg-primary"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
{t.heroButtons.create}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-white px-6 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-50"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{t.heroButtons.playground}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-[#f8f4ec] px-6 py-3 text-sm font-semibold text-slate-800 transition hover:bg-[#f2ebdd]"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
{t.heroButtons.tutorials}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-slate-900/10 pt-5">
|
||||
<p className={HOME_SECTION_LABEL_CLASS}>{t.trustedBy}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LogoPill label="Next.js" />
|
||||
<LogoPill label="Go" />
|
||||
<LogoPill label="Vercel" />
|
||||
<LogoPill label="Cloud Run" />
|
||||
<LogoPill label="PostgreSQL" />
|
||||
<div className="grid gap-3">
|
||||
{t.nextSteps.items.map((item, index: number) => {
|
||||
const example = heroCopy.examples[index] ?? item.title;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.title}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPromptSeed(example);
|
||||
setPromptSeedKey((current) => current + 1);
|
||||
}}
|
||||
className="group flex items-center justify-between gap-4 rounded-[1.25rem] border border-slate-200/90 bg-white/88 px-4 py-3.5 text-left shadow-[0_10px_26px_rgba(15,23,42,0.04)] transition duration-200 hover:-translate-y-[1px] hover:border-slate-300 hover:bg-[#fbfaf7]"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex rounded-full border border-slate-900/10 bg-[#f8f4ec] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-600">
|
||||
{item.status}
|
||||
</span>
|
||||
<p className="truncate text-sm font-semibold text-slate-900">
|
||||
{item.title}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-slate-500">
|
||||
{heroCopy.itemHint}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-slate-400 transition group-hover:text-slate-700" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:pl-4">
|
||||
<HeroVideoShell
|
||||
items={t.heroCards.map((card) => card.title)}
|
||||
isChinese={isChinese}
|
||||
media={heroVideoMedia}
|
||||
<div className="overflow-hidden rounded-[2rem] border border-slate-900/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.94),rgba(246,248,251,0.98))] shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
|
||||
<div className="border-b border-slate-900/10 px-5 py-4 sm:px-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className={HOME_SECTION_LABEL_CLASS}>
|
||||
{isChinese ? "X 助手" : "X Assistant"}
|
||||
</p>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-text-muted">
|
||||
{isChinese
|
||||
? "首页只保留一个主路径:先提问,再由助手拆解任务、调用能力并推进执行。"
|
||||
: "The homepage keeps one primary path: ask first, then let the assistant plan and execute."}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden rounded-full border border-slate-900/10 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-600 sm:inline-flex">
|
||||
{isChinese ? "对话即入口" : "Prompt-first"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-5">
|
||||
<OpenClawAssistantPane
|
||||
defaults={assistantDefaultsSWR.data ?? EMPTY_ASSISTANT_DEFAULTS}
|
||||
initialQuestion={promptSeed}
|
||||
initialQuestionKey={promptSeedKey}
|
||||
autoSubmitInitialQuestion={false}
|
||||
variant="page"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function NextStepsSection() {
|
||||
const { language } = useLanguage();
|
||||
const t = translations[language].marketing.home;
|
||||
|
||||
return (
|
||||
<section className={cn(HOME_SECTION_CLASS, "space-y-4 p-5 lg:p-7")}>
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className={HOME_SECTION_LABEL_CLASS}>{t.nextSteps.title}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-text-muted">
|
||||
{language === "zh"
|
||||
? "保留原有 onboarding 内容,但改成更轻、更整齐的起步列表。"
|
||||
: "Keep the same onboarding content, in a lighter and calmer starting list."}
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex w-fit items-center rounded-full border border-slate-900/10 bg-[#f8f4ec] px-3 py-1 text-xs font-semibold text-slate-700">
|
||||
{t.nextSteps.badge}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
{t.nextSteps.items.map((item, index: number) => {
|
||||
const Icon = getIcon(item.title, Users);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
HOME_LIST_CARD_CLASS,
|
||||
"flex h-full flex-col justify-between gap-6 p-4 hover:-translate-y-[1px] hover:bg-white",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-900/[0.04] text-primary">
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<span className="rounded-full border border-slate-900/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-base font-semibold leading-7 text-slate-900">
|
||||
{item.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center gap-1 text-sm font-semibold text-primary transition hover:text-primary-hover">
|
||||
{t.nextSteps.learnMore}
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@ -476,155 +581,72 @@ type LatestBlogPost = {
|
||||
date?: string;
|
||||
};
|
||||
|
||||
function HeroVideoShell({
|
||||
items,
|
||||
function DemoVideoSurface({
|
||||
presentation,
|
||||
isChinese,
|
||||
media,
|
||||
}: {
|
||||
items: string[];
|
||||
presentation: HomepageVideoPresentation;
|
||||
isChinese: boolean;
|
||||
media: HeroVideoMedia;
|
||||
}) {
|
||||
const mediaTitle = isChinese ? media.title.zh : media.title.en;
|
||||
const mediaDescription = isChinese
|
||||
? media.description.zh
|
||||
: media.description.en;
|
||||
const mediaStatusLabel = isChinese
|
||||
? media.statusLabel.zh
|
||||
: media.statusLabel.en;
|
||||
const hasVideo = Boolean(media.videoUrl);
|
||||
const previewStyle = media.posterUrl
|
||||
const fallbackStyle =
|
||||
presentation.posterUrl && presentation.kind === "empty"
|
||||
? {
|
||||
backgroundImage: `linear-gradient(180deg,rgba(15,23,42,0.18),rgba(15,23,42,0.52)), url(${media.posterUrl})`,
|
||||
backgroundImage: `linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42)), url(${presentation.posterUrl})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[2rem] border border-slate-900/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,247,252,0.96))] shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
|
||||
<div className="border-b border-slate-900/10 px-5 py-4 sm:px-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className={HOME_SECTION_LABEL_CLASS}>
|
||||
{isChinese ? "产品演示" : "Product demo"}
|
||||
</p>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-text-muted">
|
||||
{isChinese
|
||||
? "这里预留为视频展示区,后续可以直接替换成产品介绍、工作流演示或 onboarding 视频。"
|
||||
: "Reserved for a video showcase. You can later replace it with a product intro, workflow demo, or onboarding clip."}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden rounded-full border border-slate-900/10 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-600 sm:inline-flex">
|
||||
{isChinese ? "16:9 占位" : "16:9 shell"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 sm:p-5">
|
||||
<div
|
||||
className={cn(
|
||||
"group relative aspect-video overflow-hidden rounded-[1.6rem] border border-slate-900/10",
|
||||
!hasVideo &&
|
||||
"bg-[radial-gradient(circle_at_top_left,rgba(51,102,255,0.16),transparent_34%),linear-gradient(135deg,#0f172a,#172033_52%,#1f2d4d)]",
|
||||
"group relative aspect-video overflow-hidden rounded-[1.6rem] border border-slate-900/10 bg-[radial-gradient(circle_at_top_left,rgba(51,102,255,0.16),transparent_34%),linear-gradient(135deg,#0f172a,#172033_52%,#1f2d4d)]",
|
||||
presentation.kind !== "empty" && "bg-slate-950",
|
||||
)}
|
||||
style={previewStyle}
|
||||
style={fallbackStyle}
|
||||
>
|
||||
{hasVideo ? (
|
||||
{presentation.kind === "embed" ? (
|
||||
<iframe
|
||||
src={presentation.src}
|
||||
title={isChinese ? "产品演示视频" : "Product demo video"}
|
||||
className="h-full w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{presentation.kind === "direct" ? (
|
||||
<video
|
||||
className="h-full w-full object-cover"
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={media.posterUrl || undefined}
|
||||
poster={presentation.posterUrl || undefined}
|
||||
>
|
||||
<source src={media.videoUrl} />
|
||||
<source src={presentation.src} />
|
||||
</video>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(15,23,42,0.18))]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute left-5 top-5 h-20 w-20 rounded-full bg-[radial-gradient(circle,rgba(255,255,255,0.18),transparent_70%)] blur-2xl"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute right-[-1.5rem] top-[-1.5rem] h-28 w-28 rounded-full border border-white/10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 flex flex-col justify-between p-5 sm:p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/80 backdrop-blur">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
{mediaStatusLabel}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/10 px-3 py-1 text-xs font-medium text-white/70">
|
||||
{media.durationLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!hasVideo ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-16 w-16 items-center justify-center rounded-full border border-white/15 bg-white/12 text-white shadow-[0_14px_35px_rgba(15,23,42,0.25)] backdrop-blur transition group-hover:scale-[1.02]"
|
||||
>
|
||||
<Play className="ml-1 h-7 w-7" fill="currentColor" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{presentation.kind === "empty" ? (
|
||||
<div className="absolute inset-0 flex flex-col justify-between p-5 sm:p-6">
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/80 backdrop-blur">
|
||||
<span className="h-2 w-2 rounded-full bg-amber-300" />
|
||||
{isChinese ? "待接入视频" : "Awaiting media"}
|
||||
</div>
|
||||
<div className="max-w-lg space-y-2">
|
||||
<p className="text-xl font-semibold tracking-[-0.03em] text-white sm:text-2xl">
|
||||
{mediaTitle}
|
||||
{isChinese
|
||||
? "这里会展示当前域名的视频演示"
|
||||
: "This area shows the domain-specific product demo"}
|
||||
</p>
|
||||
<p className="text-sm leading-6 text-white/72 sm:text-[0.95rem]">
|
||||
{mediaDescription}
|
||||
{isChinese
|
||||
? "管理页可为不同域名配置不同链接。若链接不是可嵌入或直链格式,这里会保留为占位状态。"
|
||||
: "The admin page can assign a different link per host. Unsupported links remain in placeholder mode until a valid embed or direct video URL is provided."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-white/12">
|
||||
<div className="h-full w-[28%] rounded-full bg-white/75" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px] font-medium text-white/60 sm:text-xs">
|
||||
{media.chapters.map((chapter) => (
|
||||
<span
|
||||
key={chapter.en}
|
||||
className="rounded-full border border-white/10 bg-white/8 px-3 py-2 text-center"
|
||||
>
|
||||
{isChinese ? chapter.zh : chapter.en}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="inline-flex items-center rounded-full border border-slate-900/10 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoPill({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-white px-3.5 py-1.5 text-xs font-semibold text-slate-700">
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -49,6 +49,7 @@ type OpenClawAssistantPaneProps = {
|
||||
defaults: IntegrationDefaults;
|
||||
initialQuestion?: string;
|
||||
initialQuestionKey?: number;
|
||||
autoSubmitInitialQuestion?: boolean;
|
||||
variant?: "page" | "sidebar";
|
||||
showConversation?: boolean;
|
||||
emptyConversationHint?: string;
|
||||
@ -214,6 +215,7 @@ export function OpenClawAssistantPane({
|
||||
defaults,
|
||||
initialQuestion,
|
||||
initialQuestionKey,
|
||||
autoSubmitInitialQuestion = true,
|
||||
variant = "page",
|
||||
showConversation = true,
|
||||
emptyConversationHint,
|
||||
@ -778,8 +780,16 @@ export function OpenClawAssistantPane({
|
||||
|
||||
lastInitialQuestionKeyRef.current = resolvedKey;
|
||||
setComposerValue(initialQuestion);
|
||||
if (autoSubmitInitialQuestion) {
|
||||
void sendMessage(initialQuestion);
|
||||
}, [connectionState, initialQuestion, initialQuestionKey, sendMessage]);
|
||||
}
|
||||
}, [
|
||||
autoSubmitInitialQuestion,
|
||||
connectionState,
|
||||
initialQuestion,
|
||||
initialQuestionKey,
|
||||
sendMessage,
|
||||
]);
|
||||
|
||||
function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
|
||||
191
src/lib/home/homepageVideo.ts
Normal file
191
src/lib/home/homepageVideo.ts
Normal file
@ -0,0 +1,191 @@
|
||||
export type HomepageVideoEntry = {
|
||||
domain?: string;
|
||||
videoUrl: string;
|
||||
posterUrl?: string;
|
||||
};
|
||||
|
||||
export type HomepageVideoSettingsResponse = {
|
||||
defaultEntry: HomepageVideoEntry;
|
||||
overrides: HomepageVideoEntry[];
|
||||
};
|
||||
|
||||
export type ResolvedHomepageVideoResponse = {
|
||||
resolved: HomepageVideoEntry;
|
||||
};
|
||||
|
||||
export type HomepageVideoPresentation =
|
||||
| {
|
||||
kind: "embed";
|
||||
provider: "youtube" | "bilibili";
|
||||
src: string;
|
||||
posterUrl?: string;
|
||||
videoUrl: string;
|
||||
}
|
||||
| {
|
||||
kind: "direct";
|
||||
provider: "direct";
|
||||
src: string;
|
||||
posterUrl?: string;
|
||||
videoUrl: string;
|
||||
}
|
||||
| {
|
||||
kind: "empty";
|
||||
provider: "none";
|
||||
src: "";
|
||||
posterUrl?: string;
|
||||
videoUrl: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_HOMEPAGE_VIDEO_SETTINGS: HomepageVideoSettingsResponse = {
|
||||
defaultEntry: {
|
||||
videoUrl: "https://www.youtube.com/watch?v=UW6DY6HQnmo",
|
||||
posterUrl: "",
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
domain: "cn-www.svc.plus",
|
||||
videoUrl:
|
||||
"https://www.bilibili.com/video/BV12DwazxEkL/?spm_id_from=333.1387.homepage.video_card.click&vd_source=e14d146f9a815c7d11e1a1fc23565ffd",
|
||||
posterUrl: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function trimValue(value?: string): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function normalizeHomepageVideoEntry(
|
||||
entry?: HomepageVideoEntry,
|
||||
): HomepageVideoEntry {
|
||||
return {
|
||||
domain: trimValue(entry?.domain),
|
||||
videoUrl: trimValue(entry?.videoUrl),
|
||||
posterUrl: trimValue(entry?.posterUrl),
|
||||
};
|
||||
}
|
||||
|
||||
function tryParseUrl(raw: string): URL | null {
|
||||
const trimmed = trimValue(raw);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveYoutubeEmbed(url: URL): string | undefined {
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (host === "youtu.be") {
|
||||
const videoId = url.pathname.replace(/^\/+/, "").split("/")[0];
|
||||
return videoId ? `https://www.youtube.com/embed/${videoId}` : undefined;
|
||||
}
|
||||
|
||||
if (host.endsWith("youtube.com")) {
|
||||
if (url.pathname === "/watch") {
|
||||
const videoId = trimValue(url.searchParams.get("v") ?? "");
|
||||
return videoId ? `https://www.youtube.com/embed/${videoId}` : undefined;
|
||||
}
|
||||
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
const videoId = segments.at(-1);
|
||||
if (segments[0] === "embed" && videoId) {
|
||||
return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
if ((segments[0] === "shorts" || segments[0] === "live") && videoId) {
|
||||
return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveBilibiliEmbed(url: URL): string | undefined {
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (!host.endsWith("bilibili.com")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = url.pathname.match(/\/video\/(BV[0-9A-Za-z]+)/i);
|
||||
const bvid = match?.[1];
|
||||
if (!bvid) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const page = trimValue(url.searchParams.get("p") ?? "1") || "1";
|
||||
return `https://player.bilibili.com/player.html?bvid=${encodeURIComponent(bvid)}&page=${encodeURIComponent(page)}`;
|
||||
}
|
||||
|
||||
function isDirectVideoUrl(url: URL): boolean {
|
||||
return /\.(mp4|webm|ogg|mov|m4v)(\?|#|$)/i.test(url.pathname);
|
||||
}
|
||||
|
||||
export function resolveHomepageVideoPresentation(
|
||||
entry?: HomepageVideoEntry,
|
||||
): HomepageVideoPresentation {
|
||||
const normalized = normalizeHomepageVideoEntry(entry);
|
||||
if (!normalized.videoUrl) {
|
||||
return {
|
||||
kind: "empty",
|
||||
provider: "none",
|
||||
src: "",
|
||||
posterUrl: normalized.posterUrl,
|
||||
videoUrl: "",
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = tryParseUrl(normalized.videoUrl);
|
||||
if (!parsed) {
|
||||
return {
|
||||
kind: "empty",
|
||||
provider: "none",
|
||||
src: "",
|
||||
posterUrl: normalized.posterUrl,
|
||||
videoUrl: "",
|
||||
};
|
||||
}
|
||||
|
||||
const youtubeEmbed = resolveYoutubeEmbed(parsed);
|
||||
if (youtubeEmbed) {
|
||||
return {
|
||||
kind: "embed",
|
||||
provider: "youtube",
|
||||
src: youtubeEmbed,
|
||||
posterUrl: normalized.posterUrl,
|
||||
videoUrl: normalized.videoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const bilibiliEmbed = resolveBilibiliEmbed(parsed);
|
||||
if (bilibiliEmbed) {
|
||||
return {
|
||||
kind: "embed",
|
||||
provider: "bilibili",
|
||||
src: bilibiliEmbed,
|
||||
posterUrl: normalized.posterUrl,
|
||||
videoUrl: normalized.videoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (isDirectVideoUrl(parsed)) {
|
||||
return {
|
||||
kind: "direct",
|
||||
provider: "direct",
|
||||
src: normalized.videoUrl,
|
||||
posterUrl: normalized.posterUrl,
|
||||
videoUrl: normalized.videoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "empty",
|
||||
provider: "none",
|
||||
src: "",
|
||||
posterUrl: normalized.posterUrl,
|
||||
videoUrl: normalized.videoUrl,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user