perf: speed up home and canvas entry
This commit is contained in:
parent
a7b9533d72
commit
f7dba9cb41
@ -1,66 +1,71 @@
|
||||
"use client";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
type CanvasRedirectPageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
// 默认进入最近画布,避免误触直接新建
|
||||
export default function CanvasRedirectPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const shouldCreateNew = searchParams.get("new") === "1";
|
||||
const passthroughParams = new URLSearchParams(searchParams.toString());
|
||||
passthroughParams.delete("new");
|
||||
const passthroughQuery = passthroughParams.toString();
|
||||
const buildCanvasUrl = (canvasId: string) =>
|
||||
`/canvas/${canvasId}${passthroughQuery ? `?${passthroughQuery}` : ""}`;
|
||||
|
||||
const openCanvas = async () => {
|
||||
if (shouldCreateNew) {
|
||||
const newId = crypto.randomUUID();
|
||||
router.replace(buildCanvasUrl(newId));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/canvas?limit=1&page=1", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const items = result?.data || [];
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const aTime = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
||||
const bTime = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
const latest = sorted[0];
|
||||
if (latest?.id) {
|
||||
router.replace(buildCanvasUrl(latest.id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore and fallback
|
||||
}
|
||||
|
||||
const newId = crypto.randomUUID();
|
||||
router.replace(buildCanvasUrl(newId));
|
||||
};
|
||||
|
||||
void openCanvas();
|
||||
}, [router, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">正在打开画布...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function getSearchParam(
|
||||
params: Record<string, string | string[] | undefined>,
|
||||
key: string
|
||||
) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function buildCanvasUrl(
|
||||
canvasId: string,
|
||||
params: Record<string, string | string[] | undefined>
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (key === "new" || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => query.append(key, item));
|
||||
continue;
|
||||
}
|
||||
query.set(key, value);
|
||||
}
|
||||
const queryString = query.toString();
|
||||
return `/canvas/${canvasId}${queryString ? `?${queryString}` : ""}`;
|
||||
}
|
||||
|
||||
async function getLatestCanvasId(userId: string) {
|
||||
const [latestProject] = await db
|
||||
.select({ id: canvasProject.id })
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.userId, userId),
|
||||
eq(canvasProject.workspaceType, "canvas")
|
||||
)
|
||||
)
|
||||
.orderBy(desc(canvasProject.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
return latestProject?.id || null;
|
||||
}
|
||||
|
||||
export default async function CanvasRedirectPage({
|
||||
searchParams,
|
||||
}: CanvasRedirectPageProps) {
|
||||
const params = (await searchParams) || {};
|
||||
const shouldCreateNew = getSearchParam(params, "new") === "1";
|
||||
|
||||
if (shouldCreateNew) {
|
||||
redirect(buildCanvasUrl(randomUUID(), params));
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const latestCanvasId = session?.user?.id
|
||||
? await getLatestCanvasId(session.user.id).catch(() => null)
|
||||
: null;
|
||||
|
||||
redirect(buildCanvasUrl(latestCanvasId || randomUUID(), params));
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { cookies } from "next/headers";
|
||||
import Script from "next/script";
|
||||
import { Suspense } from "react";
|
||||
import { ChatShell } from "@/components/chat-shell";
|
||||
import { DataStreamProvider } from "@/components/data-stream-provider";
|
||||
@ -13,17 +12,11 @@ import {
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<DataStreamProvider>
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<SidebarWrapper>{children}</SidebarWrapper>
|
||||
</Suspense>
|
||||
</DataStreamProvider>
|
||||
</>
|
||||
<DataStreamProvider>
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<SidebarWrapper>{children}</SidebarWrapper>
|
||||
</Suspense>
|
||||
</DataStreamProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,61 +1,79 @@
|
||||
"use client";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
type VideoCanvasRedirectPageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
export default function VideoCanvasRedirectPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const shouldCreateNew = searchParams.get("new") === "1";
|
||||
|
||||
const openVideoCanvas = async () => {
|
||||
if (shouldCreateNew) {
|
||||
const newId = crypto.randomUUID();
|
||||
router.replace(`/canvas/${newId}?mode=video&workspaceType=video`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/canvas?workspaceType=video&limit=1&page=1",
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const items = result?.data || [];
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const aTime = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
||||
const bTime = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
const latest = sorted[0];
|
||||
if (latest?.id) {
|
||||
router.replace(`/canvas/${latest.id}?mode=video&workspaceType=video`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore and fallback
|
||||
}
|
||||
|
||||
const newId = crypto.randomUUID();
|
||||
router.replace(`/canvas/${newId}?mode=video&workspaceType=video`);
|
||||
};
|
||||
|
||||
void openVideoCanvas();
|
||||
}, [router, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">正在打开视频画布...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function getSearchParam(
|
||||
params: Record<string, string | string[] | undefined>,
|
||||
key: string
|
||||
) {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function buildVideoCanvasUrl(
|
||||
canvasId: string,
|
||||
params: Record<string, string | string[] | undefined>
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
query.set("mode", "video");
|
||||
query.set("workspaceType", "video");
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (
|
||||
key === "new" ||
|
||||
key === "mode" ||
|
||||
key === "workspaceType" ||
|
||||
value === undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => query.append(key, item));
|
||||
continue;
|
||||
}
|
||||
query.set(key, value);
|
||||
}
|
||||
|
||||
return `/canvas/${canvasId}?${query.toString()}`;
|
||||
}
|
||||
|
||||
async function getLatestVideoCanvasId(userId: string) {
|
||||
const [latestProject] = await db
|
||||
.select({ id: canvasProject.id })
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.userId, userId),
|
||||
eq(canvasProject.workspaceType, "video")
|
||||
)
|
||||
)
|
||||
.orderBy(desc(canvasProject.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
return latestProject?.id || null;
|
||||
}
|
||||
|
||||
export default async function VideoCanvasRedirectPage({
|
||||
searchParams,
|
||||
}: VideoCanvasRedirectPageProps) {
|
||||
const params = (await searchParams) || {};
|
||||
const shouldCreateNew = getSearchParam(params, "new") === "1";
|
||||
|
||||
if (shouldCreateNew) {
|
||||
redirect(buildVideoCanvasUrl(randomUUID(), params));
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const latestCanvasId = session?.user?.id
|
||||
? await getLatestVideoCanvasId(session.user.id).catch(() => null)
|
||||
: null;
|
||||
|
||||
redirect(buildVideoCanvasUrl(latestCanvasId || randomUUID(), params));
|
||||
}
|
||||
|
||||
94
components/chat-shell-chrome.tsx
Normal file
94
components/chat-shell-chrome.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import type { User } from "next-auth";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { TopNavbar } from "@/components/top-navbar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
|
||||
type CustomLink = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
order: number;
|
||||
toolboxGroup: number;
|
||||
};
|
||||
|
||||
type TutorialVideo = {
|
||||
id: string;
|
||||
title: string;
|
||||
videoUrl: string;
|
||||
coverUrl: string | null;
|
||||
description: string | null;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type ChatShellChromeProps = {
|
||||
children: React.ReactNode;
|
||||
user?: User;
|
||||
isCollapsed: boolean;
|
||||
siteName: string;
|
||||
siteLogo?: string;
|
||||
siteLogoDark?: string;
|
||||
isAdmin: boolean;
|
||||
settingsHref?: string | null;
|
||||
customLinks: CustomLink[];
|
||||
toolbox1Name: string;
|
||||
toolbox1Enabled: boolean;
|
||||
toolbox2Name: string;
|
||||
toolbox2Enabled: boolean;
|
||||
tutorialVideos: TutorialVideo[];
|
||||
tutorialEnabled: boolean;
|
||||
rankingEnabled: boolean;
|
||||
productPlanningEnabled: boolean;
|
||||
agentEnabled: boolean;
|
||||
videoCanvasEnabled: boolean;
|
||||
};
|
||||
|
||||
export function ChatShellChrome({
|
||||
children,
|
||||
user,
|
||||
isCollapsed,
|
||||
siteName,
|
||||
siteLogo,
|
||||
siteLogoDark,
|
||||
isAdmin,
|
||||
settingsHref = null,
|
||||
customLinks,
|
||||
toolbox1Name,
|
||||
toolbox1Enabled,
|
||||
toolbox2Name,
|
||||
toolbox2Enabled,
|
||||
tutorialVideos,
|
||||
tutorialEnabled,
|
||||
rankingEnabled,
|
||||
productPlanningEnabled,
|
||||
agentEnabled,
|
||||
videoCanvasEnabled,
|
||||
}: ChatShellChromeProps) {
|
||||
return (
|
||||
<SidebarProvider defaultOpen={!isCollapsed}>
|
||||
<TopNavbar
|
||||
agentEnabled={agentEnabled}
|
||||
customLinks={customLinks}
|
||||
isAdmin={isAdmin}
|
||||
productPlanningEnabled={productPlanningEnabled}
|
||||
rankingEnabled={rankingEnabled}
|
||||
settingsHref={settingsHref}
|
||||
siteLogo={siteLogo}
|
||||
siteLogoDark={siteLogoDark}
|
||||
siteName={siteName}
|
||||
toolbox1Enabled={toolbox1Enabled}
|
||||
toolbox1Name={toolbox1Name}
|
||||
toolbox2Enabled={toolbox2Enabled}
|
||||
toolbox2Name={toolbox2Name}
|
||||
tutorialEnabled={tutorialEnabled}
|
||||
tutorialVideos={tutorialVideos}
|
||||
videoCanvasEnabled={videoCanvasEnabled}
|
||||
/>
|
||||
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
|
||||
<AppSidebar user={user} />
|
||||
<SidebarInset className="h-full">{children}</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { User } from "next-auth";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { TopNavbar } from "@/components/top-navbar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import type { ChatShellChromeProps } from "@/components/chat-shell-chrome";
|
||||
|
||||
type CustomLink = {
|
||||
id: string;
|
||||
@ -46,6 +45,17 @@ type ChatShellProps = {
|
||||
videoCanvasEnabled: boolean;
|
||||
};
|
||||
|
||||
const ChatShellChrome = dynamic(
|
||||
() =>
|
||||
import("@/components/chat-shell-chrome").then(
|
||||
(module) => module.ChatShellChrome
|
||||
),
|
||||
{
|
||||
loading: () => <div className="h-svh w-full overflow-hidden" />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
export function ChatShell({
|
||||
children,
|
||||
user,
|
||||
@ -81,30 +91,27 @@ export function ChatShell({
|
||||
return <div className="h-svh w-full overflow-hidden">{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={!isCollapsed}>
|
||||
<TopNavbar
|
||||
agentEnabled={agentEnabled}
|
||||
customLinks={customLinks}
|
||||
isAdmin={isAdmin}
|
||||
productPlanningEnabled={productPlanningEnabled}
|
||||
rankingEnabled={rankingEnabled}
|
||||
settingsHref={settingsHref}
|
||||
siteLogo={siteLogo}
|
||||
siteLogoDark={siteLogoDark}
|
||||
siteName={siteName}
|
||||
toolbox1Enabled={toolbox1Enabled}
|
||||
toolbox1Name={toolbox1Name}
|
||||
toolbox2Enabled={toolbox2Enabled}
|
||||
toolbox2Name={toolbox2Name}
|
||||
tutorialEnabled={tutorialEnabled}
|
||||
tutorialVideos={tutorialVideos}
|
||||
videoCanvasEnabled={videoCanvasEnabled}
|
||||
/>
|
||||
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
|
||||
<AppSidebar user={user} />
|
||||
<SidebarInset className="h-full">{children}</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
const chromeProps: ChatShellChromeProps = {
|
||||
agentEnabled,
|
||||
children,
|
||||
customLinks,
|
||||
isAdmin,
|
||||
isCollapsed,
|
||||
productPlanningEnabled,
|
||||
rankingEnabled,
|
||||
settingsHref,
|
||||
siteLogo,
|
||||
siteLogoDark,
|
||||
siteName,
|
||||
toolbox1Enabled,
|
||||
toolbox1Name,
|
||||
toolbox2Enabled,
|
||||
toolbox2Name,
|
||||
tutorialEnabled,
|
||||
tutorialVideos,
|
||||
user,
|
||||
videoCanvasEnabled,
|
||||
};
|
||||
|
||||
return <ChatShellChrome {...chromeProps} />;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn, signOut } from "next-auth/react";
|
||||
import { useTheme } from "next-themes";
|
||||
@ -65,7 +66,6 @@ import {
|
||||
getCanvasLaunchStorageKey,
|
||||
} from "@/lib/canvas-launch";
|
||||
import type { MembershipConfig } from "@/lib/membership/config";
|
||||
import { ImageAnnotator } from "@/components/image-annotator";
|
||||
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
|
||||
import { FloatingSidebarRail } from "@/components/home/floating-sidebar-shell";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
@ -74,6 +74,17 @@ import {
|
||||
type LovartHomeSidebarItemKey,
|
||||
} from "@/lib/home/lovart-sidebar-config";
|
||||
|
||||
const ImageAnnotator = dynamic(
|
||||
() =>
|
||||
import("@/components/image-annotator").then(
|
||||
(module) => module.ImageAnnotator
|
||||
),
|
||||
{
|
||||
loading: () => null,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
type HomeUser = {
|
||||
id: string;
|
||||
email?: string | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user