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