feat: add home theme selector and canvas polish

This commit is contained in:
Your Name 2026-04-24 16:36:06 +08:00
parent 560a1999a0
commit 5b7d6a6e89
4 changed files with 205 additions and 15 deletions

View File

@ -9627,10 +9627,10 @@ export function CanvasChatPanel({
{message.role === 'user' ? (
<div
className={cn(
"rounded-2xl px-3 py-2",
"rounded-2xl border px-3 py-2 shadow-sm",
isVideoWorkspace
? "border border-border/60 bg-background/80 shadow-sm"
: "bg-[#f1f2f4]"
? "border-border/60 bg-background/80 text-foreground"
: "border-black/5 bg-[#f1f2f4] text-[#1f2937] dark:border-white/10 dark:bg-[#1b1f2a] dark:text-zinc-50"
)}
>
<div className="flex flex-wrap items-center gap-1.5">
@ -9653,7 +9653,7 @@ export function CanvasChatPanel({
"inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs",
isVideoWorkspace
? "border border-border/60 bg-background"
: "bg-white/90"
: "border border-black/5 bg-white/90 text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
)}
>
<img
@ -9668,7 +9668,7 @@ export function CanvasChatPanel({
}
}}
/>
<span className="max-w-[80px] truncate text-foreground">{part.name || 'Image'}</span>
<span className="max-w-[80px] truncate text-current">{part.name || 'Image'}</span>
</span>
);
return;
@ -9680,7 +9680,7 @@ export function CanvasChatPanel({
.trim();
if (!text) return;
renderedUserParts.push(
<span key={`part-${idx}`} className="text-sm text-foreground">{text}</span>
<span key={`part-${idx}`} className="text-sm text-current">{text}</span>
);
}
});
@ -9694,7 +9694,7 @@ export function CanvasChatPanel({
renderedUserParts.push(
<span
key={`att-${idx}`}
className="inline-flex items-center gap-1 rounded-xl bg-white/90 px-2 py-1 text-xs"
className="inline-flex items-center gap-1 rounded-xl border border-black/5 bg-white/90 px-2 py-1 text-xs text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
>
<img
src={buildPreviewImageUrl(att.url, 48)}
@ -9708,7 +9708,7 @@ export function CanvasChatPanel({
}
}}
/>
<span className="max-w-[80px] truncate text-foreground">{att.name || 'Image'}</span>
<span className="max-w-[80px] truncate text-current">{att.name || 'Image'}</span>
</span>
);
});
@ -9719,7 +9719,7 @@ export function CanvasChatPanel({
extractUserTextPrompt(message as any) ||
(isVideoModel ? "已发送视频生成请求" : "已发送请求");
renderedUserParts.push(
<span key="fallback-text" className="text-sm text-foreground">
<span key="fallback-text" className="text-sm text-current">
{fallbackText}
</span>
);
@ -9733,7 +9733,7 @@ export function CanvasChatPanel({
<>
<div
className={cn(
"min-w-0 max-w-full text-[15px] text-[#2f3640]",
"min-w-0 max-w-full text-[15px] text-[#2f3640] dark:text-zinc-300",
isImageOnlyAssistant
? "bg-transparent px-0 py-0"
: "bg-transparent px-0 py-0"

View File

@ -200,6 +200,10 @@ export function World3DNode({
() => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76),
[node.size.height, node.size.width]
);
const overlayChromeScale = useMemo(
() => clampNumber(getNodeContentScale(node.size, { width: 420, height: 320 }, 0.48), 0.48, 1),
[node.size.height, node.size.width]
);
const placeholderViewportScale = useMemo(
() =>
clampNumber(
@ -223,6 +227,37 @@ export function World3DNode({
}),
[placeholderViewportScale]
);
const topLeftControlsStyle = useMemo<React.CSSProperties>(
() => ({
transform: `scale(${overlayChromeScale})`,
transformOrigin: "top left",
}),
[overlayChromeScale]
);
const topRightControlsStyle = useMemo<React.CSSProperties>(
() => ({
transform: `scale(${overlayChromeScale})`,
transformOrigin: "top right",
}),
[overlayChromeScale]
);
const bottomOverlayStyle = useMemo<React.CSSProperties>(
() => ({
paddingLeft: `${12 * overlayChromeScale}px`,
paddingRight: `${12 * overlayChromeScale}px`,
paddingBottom: `${12 * overlayChromeScale}px`,
paddingTop: `${48 * overlayChromeScale}px`,
}),
[overlayChromeScale]
);
const bottomOverlayContentStyle = useMemo<React.CSSProperties>(
() => ({
transform: `scale(${overlayChromeScale})`,
transformOrigin: "bottom left",
width: `${100 / overlayChromeScale}%`,
}),
[overlayChromeScale]
);
const captureEnabled = data.status === "completed" && (activeView === "splat" || activeView === "mesh");
const handleSetView = (view: World3DViewMode) => {
@ -457,8 +492,14 @@ export function World3DNode({
/>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent px-3 pb-3 pt-12">
<div className="flex items-end justify-between gap-3">
<div
className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent"
style={bottomOverlayStyle}
>
<div
className="flex items-end justify-between gap-3"
style={bottomOverlayContentStyle}
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">
{data.prompt || "3D 世界"}
@ -485,7 +526,10 @@ export function World3DNode({
</>
)}
<div className="absolute left-3 top-3 z-20 flex items-center gap-2">
<div
className="absolute left-3 top-3 z-20 flex items-center gap-2"
style={topLeftControlsStyle}
>
<div className="flex rounded-full border border-white/10 bg-black/55 p-1 backdrop-blur-sm">
{VIEW_META.filter((view) => availableViews.includes(view.id)).map((view) => (
(() => {
@ -569,7 +613,10 @@ export function World3DNode({
</div>
{isHovered && data.status === "completed" && (
<div className="absolute right-3 top-3 z-20 flex items-center gap-1">
<div
className="absolute right-3 top-3 z-20 flex items-center gap-1"
style={topRightControlsStyle}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View File

@ -1018,6 +1018,7 @@ function WorldSplatCaptureBridge({
function WorldSplatViewer({
splatUrl,
worldUrl,
previewImageUrl,
firstPerson,
hotspots,
@ -1029,6 +1030,7 @@ function WorldSplatViewer({
onCaptureReady,
}: {
splatUrl?: string | null;
worldUrl?: string | null;
previewImageUrl?: string | null;
firstPerson?: boolean;
hotspots?: World3DHotspot[];
@ -1062,6 +1064,21 @@ function WorldSplatViewer({
}, [loadState]);
if (!splatUrl) {
if (worldUrl) {
return (
<div className="h-full w-full overflow-hidden bg-zinc-950">
<iframe
allow="accelerometer; autoplay; clipboard-read; clipboard-write; fullscreen; gyroscope; xr-spatial-tracking"
allowFullScreen
className="h-full w-full border-0"
referrerPolicy="no-referrer-when-downgrade"
src={worldUrl}
title="3D 世界在线预览"
/>
</div>
);
}
return (
<WorldEmptyState
description="当前结果没有返回可用的世界数据。"
@ -1073,6 +1090,21 @@ function WorldSplatViewer({
}
if (loadState === "error") {
if (worldUrl) {
return (
<div className="h-full w-full overflow-hidden bg-zinc-950">
<iframe
allow="accelerometer; autoplay; clipboard-read; clipboard-write; fullscreen; gyroscope; xr-spatial-tracking"
allowFullScreen
className="h-full w-full border-0"
referrerPolicy="no-referrer-when-downgrade"
src={worldUrl}
title="3D 世界在线预览"
/>
</div>
);
}
return (
<WorldEmptyState
description="当前世界数据暂时无法加载,可以切换到图像或 Mesh 视图继续查看。"
@ -1223,6 +1255,7 @@ export function World3DViewer({
resetViewToken={resetViewToken}
semanticsMetadata={semanticsMetadata}
splatUrl={splatUrl}
worldUrl={worldUrl}
/>
</div>
);

View File

@ -25,12 +25,14 @@ import {
LogOut,
Mail,
Monitor,
Moon,
Palette,
Plus,
PlayCircle,
Settings,
Sparkles,
Star,
Sun,
Trash2,
Upload,
User,
@ -44,6 +46,13 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
@ -235,6 +244,34 @@ type UploadFileItem = {
previewUrl: string;
};
type ThemeMode = "system" | "light" | "dark";
const THEME_MODE_OPTIONS: Array<{
value: ThemeMode;
label: string;
description: string;
icon: LucideIcon;
}> = [
{
value: "system",
label: "跟随系统",
description: "随浏览器/系统深浅色自动切换",
icon: Monitor,
},
{
value: "light",
label: "浅色模式",
description: "始终使用明亮界面",
icon: Sun,
},
{
value: "dark",
label: "深色模式",
description: "始终使用暗色界面",
icon: Moon,
},
];
const DISCOVERY_DEFAULT_TABS = [
"品牌设计",
"海报与广告",
@ -395,7 +432,7 @@ export function LovartHome({
heroSubtitleText,
}: LovartHomeProps) {
const router = useRouter();
const { resolvedTheme } = useTheme();
const { theme, resolvedTheme, setTheme } = useTheme();
const displayLogo =
resolvedTheme === "dark"
? siteLogoDark?.trim() || siteLogo?.trim() || ""
@ -1349,6 +1386,12 @@ export function LovartHome({
accountCenterData?.profile?.dailyFreeCredits ?? membershipData?.summary?.dailyFreeCredits ?? 0,
};
}, [accountCenterData?.profile, currentPlan?.monthlyCredits, currentPlan?.name, membershipData?.summary?.currentCredits, membershipData?.summary?.dailyFreeCredits, user?.email, userDisplayName]);
const selectedThemeMode: ThemeMode =
theme === "light" || theme === "dark" || theme === "system" ? theme : "system";
const selectedThemeOption =
THEME_MODE_OPTIONS.find((option) => option.value === selectedThemeMode) ||
THEME_MODE_OPTIONS[0];
const SelectedThemeIcon = selectedThemeOption.icon;
return (
<div className="min-h-svh bg-[#f4f4f4] text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
@ -1444,6 +1487,73 @@ export function LovartHome({
</HoverCard>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-[14px] border border-black/10 bg-white px-2.5 text-[12px] font-medium text-black/70 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
title={`主题模式:${selectedThemeOption.label}`}
aria-label={`主题模式:${selectedThemeOption.label}`}
>
<SelectedThemeIcon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{selectedThemeOption.label}</span>
<ChevronDown className="h-3.5 w-3.5 opacity-55" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={10}
className="w-[220px] rounded-2xl border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
>
<DropdownMenuLabel className="px-3 py-2 text-[12px] text-black/45 dark:text-white/45">
个人主题模式
</DropdownMenuLabel>
{THEME_MODE_OPTIONS.map((option) => {
const OptionIcon = option.icon;
const isSelected = selectedThemeMode === option.value;
return (
<DropdownMenuItem
key={option.value}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 text-left focus:bg-black/5 dark:focus:bg-white/8",
isSelected
? "bg-black text-white focus:bg-black focus:text-white dark:bg-white dark:text-black dark:focus:bg-white dark:focus:text-black"
: "text-black/78 dark:text-white/78"
)}
onSelect={() => setTheme(option.value)}
>
<span
className={cn(
"flex h-8 w-8 items-center justify-center rounded-xl",
isSelected
? "bg-white/14 text-white dark:bg-black/8 dark:text-black"
: "bg-black/5 text-black/65 dark:bg-white/8 dark:text-white/65"
)}
>
<OptionIcon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-semibold leading-4">
{option.label}
</span>
<span
className={cn(
"mt-0.5 block text-[11px] leading-4",
isSelected
? "text-white/70 dark:text-black/60"
: "text-black/45 dark:text-white/42"
)}
>
{option.description}
</span>
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{isAuthenticated ? (
<HoverCard openDelay={80} closeDelay={120}>
<HoverCardTrigger asChild>