372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Box,
|
||
Download,
|
||
ImageIcon,
|
||
Link2,
|
||
Sparkles,
|
||
Waypoints,
|
||
} from "lucide-react";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { World3DViewer } from "@/components/canvas/world3d/World3DViewer";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { cn } from "@/lib/utils";
|
||
import type { World3DViewMode } from "@/types/canvas";
|
||
|
||
type SharedWorldPageProps = {
|
||
title: string;
|
||
prompt?: string | null;
|
||
caption?: string | null;
|
||
model?: string | null;
|
||
worldUrl?: string | null;
|
||
imageUrl?: string | null;
|
||
meshUrl?: string | null;
|
||
splatUrl?: string | null;
|
||
previewImageUrl?: string | null;
|
||
semanticsMetadata?: Record<string, unknown> | null;
|
||
shareUrl: string;
|
||
worlds?: SharedWorldItem[];
|
||
};
|
||
|
||
type SharedWorldItem = {
|
||
id: string;
|
||
title: string;
|
||
prompt?: string | null;
|
||
caption?: string | null;
|
||
model?: string | null;
|
||
worldUrl?: string | null;
|
||
imageUrl?: string | null;
|
||
meshUrl?: string | null;
|
||
splatUrl?: string | null;
|
||
previewImageUrl?: string | null;
|
||
semanticsMetadata?: Record<string, unknown> | null;
|
||
};
|
||
|
||
const VIEW_OPTIONS: Array<{
|
||
id: World3DViewMode;
|
||
label: string;
|
||
icon: typeof Sparkles;
|
||
}> = [
|
||
{ id: "splat", label: "World", icon: Sparkles },
|
||
{ id: "image", label: "图像", icon: ImageIcon },
|
||
{ id: "mesh", label: "Mesh", icon: Box },
|
||
];
|
||
|
||
function resolveAvailableViews(params: {
|
||
worldUrl?: string | null;
|
||
imageUrl?: string | null;
|
||
meshUrl?: string | null;
|
||
splatUrl?: string | null;
|
||
}) {
|
||
const views: World3DViewMode[] = [];
|
||
if (params.splatUrl) views.push("splat");
|
||
if (params.imageUrl) views.push("image");
|
||
if (params.meshUrl) views.push("mesh");
|
||
return views;
|
||
}
|
||
|
||
function inferInitialView(views: World3DViewMode[]) {
|
||
if (views.includes("splat")) return "splat";
|
||
if (views.includes("image")) return "image";
|
||
if (views.includes("mesh")) return "mesh";
|
||
return "image";
|
||
}
|
||
|
||
export function WorldSharePage({
|
||
title,
|
||
prompt,
|
||
caption,
|
||
model,
|
||
worldUrl,
|
||
imageUrl,
|
||
meshUrl,
|
||
splatUrl,
|
||
previewImageUrl,
|
||
semanticsMetadata,
|
||
shareUrl,
|
||
worlds,
|
||
}: SharedWorldPageProps) {
|
||
const worldItems = useMemo<SharedWorldItem[]>(
|
||
() =>
|
||
worlds?.length
|
||
? worlds
|
||
: [
|
||
{
|
||
id: "shared-world",
|
||
title,
|
||
prompt,
|
||
caption,
|
||
model,
|
||
worldUrl,
|
||
imageUrl,
|
||
meshUrl,
|
||
splatUrl,
|
||
previewImageUrl,
|
||
semanticsMetadata,
|
||
},
|
||
],
|
||
[
|
||
caption,
|
||
imageUrl,
|
||
meshUrl,
|
||
model,
|
||
previewImageUrl,
|
||
prompt,
|
||
semanticsMetadata,
|
||
splatUrl,
|
||
title,
|
||
worldUrl,
|
||
worlds,
|
||
]
|
||
);
|
||
const [activeWorldId, setActiveWorldId] = useState(
|
||
() => worldItems[0]?.id || "shared-world"
|
||
);
|
||
const activeWorld =
|
||
worldItems.find((item) => item.id === activeWorldId) || worldItems[0];
|
||
const availableViews = useMemo(
|
||
() =>
|
||
resolveAvailableViews({
|
||
worldUrl: activeWorld?.worldUrl,
|
||
imageUrl: activeWorld?.imageUrl,
|
||
meshUrl: activeWorld?.meshUrl,
|
||
splatUrl: activeWorld?.splatUrl,
|
||
}),
|
||
[activeWorld?.imageUrl, activeWorld?.meshUrl, activeWorld?.splatUrl, activeWorld?.worldUrl]
|
||
);
|
||
const [activeView, setActiveView] = useState<World3DViewMode>(
|
||
inferInitialView(availableViews)
|
||
);
|
||
const [firstPerson, setFirstPerson] = useState(true);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (worldItems.some((item) => item.id === activeWorldId)) return;
|
||
setActiveWorldId(worldItems[0]?.id || "shared-world");
|
||
}, [activeWorldId, worldItems]);
|
||
|
||
useEffect(() => {
|
||
if (availableViews.includes(activeView)) return;
|
||
setActiveView(inferInitialView(availableViews));
|
||
}, [activeView, availableViews]);
|
||
|
||
useEffect(() => {
|
||
setFirstPerson(true);
|
||
}, [activeWorld?.id]);
|
||
|
||
const primaryDescription =
|
||
activeWorld?.caption || activeWorld?.prompt || "这个 3D 世界已开启只读分享。";
|
||
const activeTitle = activeWorld?.title || title;
|
||
const activeMeshUrl = activeWorld?.meshUrl || null;
|
||
|
||
const handleCopyShareUrl = async () => {
|
||
try {
|
||
const absoluteUrl = new URL(shareUrl, window.location.origin).toString();
|
||
await navigator.clipboard.writeText(absoluteUrl);
|
||
setCopied(true);
|
||
window.setTimeout(() => setCopied(false), 1500);
|
||
} catch {
|
||
setCopied(false);
|
||
}
|
||
};
|
||
|
||
const handleDownloadMesh = () => {
|
||
if (!activeMeshUrl) return;
|
||
const link = document.createElement("a");
|
||
link.href = activeMeshUrl;
|
||
link.download = `shared-world-${activeWorld?.id?.slice(0, 8) || "asset"}.glb`;
|
||
link.click();
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(24,24,27,0.96),_rgba(2,6,23,1)_68%)] text-white">
|
||
<div className="mx-auto flex min-h-screen w-full max-w-[1600px] flex-col gap-4 p-4 md:p-6">
|
||
<div className="flex flex-col gap-4 rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur md:flex-row md:items-start md:justify-between">
|
||
<div className="space-y-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge className="border-cyan-500/30 bg-cyan-500/10 text-cyan-200" variant="outline">
|
||
{worldItems.length > 1 ? `${worldItems.length} 个 3D 世界分享` : "3D 世界分享"}
|
||
</Badge>
|
||
{availableViews.map((view) => (
|
||
<Badge className="border-white/10 bg-white/5 text-zinc-300" key={view} variant="outline">
|
||
{VIEW_OPTIONS.find((item) => item.id === view)?.label || view.toUpperCase()}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
<div>
|
||
<h1 className="text-2xl font-semibold tracking-tight text-white md:text-3xl">
|
||
{activeTitle}
|
||
</h1>
|
||
<p className="mt-2 max-w-3xl text-sm leading-6 text-zinc-300">
|
||
{primaryDescription}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
className="border-white/10 bg-white/5 text-zinc-100 hover:bg-white/10"
|
||
onClick={handleCopyShareUrl}
|
||
variant="outline"
|
||
>
|
||
<Link2 className="mr-2 h-4 w-4" />
|
||
{copied ? "已复制链接" : "复制分享链接"}
|
||
</Button>
|
||
{activeMeshUrl ? (
|
||
<Button
|
||
className="border-white/10 bg-white/5 text-zinc-100 hover:bg-white/10"
|
||
onClick={handleDownloadMesh}
|
||
variant="outline"
|
||
>
|
||
<Download className="mr-2 h-4 w-4" />
|
||
下载 Mesh
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid flex-1 gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||
<div className="overflow-hidden rounded-3xl border border-white/10 bg-black/35 shadow-2xl backdrop-blur">
|
||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-white/10 px-4 py-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{VIEW_OPTIONS.filter((item) => availableViews.includes(item.id)).map((item) => (
|
||
<Button
|
||
className={cn(
|
||
"rounded-full border border-white/10 px-4",
|
||
activeView === item.id
|
||
? "bg-white text-black hover:bg-zinc-100"
|
||
: "bg-white/5 text-zinc-100 hover:bg-white/10"
|
||
)}
|
||
key={item.id}
|
||
onClick={() => {
|
||
setActiveView(item.id);
|
||
setFirstPerson(item.id === "splat");
|
||
}}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
<item.icon className="mr-2 h-4 w-4" />
|
||
{item.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
|
||
{activeView === "splat" && activeWorld?.splatUrl ? (
|
||
<Button
|
||
className={cn(
|
||
"rounded-full border border-white/10",
|
||
firstPerson
|
||
? "bg-amber-400 text-black hover:bg-amber-300"
|
||
: "bg-white/5 text-zinc-100 hover:bg-white/10"
|
||
)}
|
||
onClick={() => setFirstPerson((prev) => !prev)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
<Waypoints className="mr-2 h-4 w-4" />
|
||
{firstPerson ? "退出第一人称" : "第一人称"}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="h-[62vh] min-h-[520px]">
|
||
<World3DViewer
|
||
key={`${activeWorld?.id || "shared-world"}-${activeView}`}
|
||
className="h-full w-full"
|
||
firstPerson={firstPerson}
|
||
imageUrl={activeWorld?.imageUrl || null}
|
||
interactionMode="navigate"
|
||
meshUrl={activeWorld?.meshUrl || null}
|
||
mode={activeView}
|
||
previewImageUrl={activeWorld?.previewImageUrl || null}
|
||
semanticsMetadata={activeWorld?.semanticsMetadata || null}
|
||
showInfoOverlay={false}
|
||
showMovementControls={activeView === "splat" || activeView === "mesh"}
|
||
splatUrl={activeWorld?.splatUrl || null}
|
||
worldUrl={activeWorld?.worldUrl || null}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
{worldItems.length > 1 ? (
|
||
<div className="rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur">
|
||
<h2 className="text-sm font-medium text-white">世界列表</h2>
|
||
<div className="mt-3 space-y-2">
|
||
{worldItems.map((item, index) => {
|
||
const thumbnail = item.previewImageUrl || item.imageUrl || null;
|
||
const isActive = item.id === activeWorld?.id;
|
||
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"flex w-full items-center gap-3 rounded-2xl border border-white/10 bg-white/5 p-2 text-left transition hover:bg-white/10",
|
||
isActive && "border-cyan-300/60 bg-cyan-400/10"
|
||
)}
|
||
key={item.id}
|
||
onClick={() => setActiveWorldId(item.id)}
|
||
type="button"
|
||
>
|
||
<div className="flex h-14 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white/10">
|
||
{thumbnail ? (
|
||
<img
|
||
alt={item.title}
|
||
className="h-full w-full object-cover"
|
||
src={thumbnail}
|
||
/>
|
||
) : (
|
||
<Box className="h-5 w-5 text-zinc-500" />
|
||
)}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-[11px] text-cyan-200">世界 {index + 1}</div>
|
||
<div className="truncate text-sm font-medium text-white">
|
||
{item.title}
|
||
</div>
|
||
<div className="mt-1 truncate text-xs text-zinc-400">
|
||
{item.caption || item.prompt || item.model || "暂无描述"}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur">
|
||
<h2 className="text-sm font-medium text-white">查看说明</h2>
|
||
<div className="mt-3 space-y-2 text-sm leading-6 text-zinc-300">
|
||
<p>`World` 视图最接近原生 3D 世界体验,支持左键转向、右键平移、滚轮缩放,以及方向键或 `WASD` 六向移动。</p>
|
||
<p>当前页面是只读分享页,不会修改原项目或原画布内容。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur">
|
||
<h2 className="text-sm font-medium text-white">资源状态</h2>
|
||
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-zinc-300">
|
||
<div className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2">
|
||
<div className="text-zinc-500">World</div>
|
||
<div className="mt-1">{activeWorld?.splatUrl ? "可查看" : "暂无"}</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2">
|
||
<div className="text-zinc-500">图像</div>
|
||
<div className="mt-1">
|
||
{activeWorld?.imageUrl || activeWorld?.previewImageUrl ? "可查看" : "暂无"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2">
|
||
<div className="text-zinc-500">Mesh</div>
|
||
<div className="mt-1">{activeWorld?.meshUrl ? "可下载" : "暂无"}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|