245 lines
8.8 KiB
TypeScript
245 lines
8.8 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Box,
|
||
Download,
|
||
ImageIcon,
|
||
Link2,
|
||
Sparkles,
|
||
Waypoints,
|
||
} from "lucide-react";
|
||
import { 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;
|
||
worldUrl?: string | null;
|
||
imageUrl?: string | null;
|
||
meshUrl?: string | null;
|
||
splatUrl?: string | null;
|
||
previewImageUrl?: string | null;
|
||
semanticsMetadata?: Record<string, unknown> | null;
|
||
shareUrl: string;
|
||
};
|
||
|
||
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,
|
||
worldUrl,
|
||
imageUrl,
|
||
meshUrl,
|
||
splatUrl,
|
||
previewImageUrl,
|
||
semanticsMetadata,
|
||
shareUrl,
|
||
}: SharedWorldPageProps) {
|
||
const availableViews = useMemo(
|
||
() => resolveAvailableViews({ worldUrl, imageUrl, meshUrl, splatUrl }),
|
||
[imageUrl, meshUrl, splatUrl, worldUrl]
|
||
);
|
||
const [activeView, setActiveView] = useState<World3DViewMode>(
|
||
inferInitialView(availableViews)
|
||
);
|
||
const [firstPerson, setFirstPerson] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const primaryDescription = caption || prompt || "这个 3D 世界已开启只读分享。";
|
||
|
||
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 (!meshUrl) return;
|
||
const link = document.createElement("a");
|
||
link.href = meshUrl;
|
||
link.download = "shared-world.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">
|
||
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">
|
||
{title}
|
||
</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>
|
||
{meshUrl ? (
|
||
<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);
|
||
if (item.id !== "splat") {
|
||
setFirstPerson(false);
|
||
}
|
||
}}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
<item.icon className="mr-2 h-4 w-4" />
|
||
{item.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
|
||
{activeView === "splat" && 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
|
||
className="h-full w-full"
|
||
firstPerson={firstPerson}
|
||
imageUrl={imageUrl}
|
||
interactionMode="navigate"
|
||
meshUrl={meshUrl}
|
||
mode={activeView}
|
||
previewImageUrl={previewImageUrl}
|
||
semanticsMetadata={semanticsMetadata}
|
||
splatUrl={splatUrl}
|
||
worldUrl={worldUrl}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<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">{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">{imageUrl || 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">{meshUrl ? "可下载" : "暂无"}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|