diff --git a/e2e/helpers/playgroundMonacoEditor.ts b/e2e/helpers/playgroundMonacoEditor.ts index e8ff49fe..1988a1da 100644 --- a/e2e/helpers/playgroundMonacoEditor.ts +++ b/e2e/helpers/playgroundMonacoEditor.ts @@ -89,6 +89,8 @@ export async function clickAppBarHomeLink(page: Page): Promise { .getByTestId("app-bar-home-link") .locator("visible=true"); + await homeLinks.first().waitFor({ state: "visible", timeout: 30_000 }); + const linkCount = await homeLinks.count(); if (linkCount === 0) { throw new Error("No visible app bar home link found"); diff --git a/e2e/helpers/playgroundRoute.ts b/e2e/helpers/playgroundRoute.ts new file mode 100644 index 00000000..3c5507c8 --- /dev/null +++ b/e2e/helpers/playgroundRoute.ts @@ -0,0 +1,24 @@ +/** Canonical invert-binary-tree path segments (project / case / solution). */ +export const INVERT_BINARY_TREE_CANONICAL_PATH = + "/playground/invert-binary-tree/case-1/solution-1"; + +export function isCanonicalPlaygroundProjectPath( + pathname: string, + projectSlug: string, +): boolean { + const segments = pathname.split("/").filter(Boolean); + const playgroundIndex = segments.indexOf("playground"); + if (playgroundIndex === -1) { + return false; + } + + const projectIndex = playgroundIndex + 1; + return ( + segments[projectIndex] === projectSlug && + segments.length >= projectIndex + 3 + ); +} + +export function isInvertBinaryTreeCanonicalPath(pathname: string): boolean { + return isCanonicalPlaygroundProjectPath(pathname, "invert-binary-tree"); +} diff --git a/e2e/home-webgl-nav.spec.ts b/e2e/home-webgl-nav.spec.ts index de691bbc..467df5fb 100644 --- a/e2e/home-webgl-nav.spec.ts +++ b/e2e/home-webgl-nav.spec.ts @@ -17,21 +17,26 @@ test.describe("home landing WebGL canvases", () => { test("keep active WebGL contexts after client navigation away and back", async ({ page, }) => { - const contextLostMessages = collectWebGlContextLostMessages(page); - await page.goto("/"); await dismissCookieBannerIfVisible(page); await waitForActiveLandingWebGLCanvases(page); await clickFooterPrivacyPolicyLink(page); await page.waitForURL((url) => url.pathname === "/privacy"); + await page + .getByTestId("app-bar-home-link") + .locator("visible=true") + .first() + .waitFor({ state: "visible", timeout: 30_000 }); + + const contextLostAfterReturn = collectWebGlContextLostMessages(page); await clickAppBarHomeLink(page); await page.waitForURL((url) => url.pathname === "/"); const activeCanvases = await waitForActiveLandingWebGLCanvases(page); expect(activeCanvases.length).toBeGreaterThanOrEqual(2); - expect(contextLostMessages).toEqual([]); + expect(contextLostAfterReturn).toEqual([]); }); test("remounts landing canvases after forced WEBGL_lose_context", async ({ diff --git a/e2e/instant-playground-nav.spec.ts b/e2e/instant-playground-nav.spec.ts index 6b59048b..c2a371b0 100644 --- a/e2e/instant-playground-nav.spec.ts +++ b/e2e/instant-playground-nav.spec.ts @@ -6,6 +6,7 @@ import { visiblePlaygroundMonacoEditor, waitForPlaygroundMonacoEditor, } from "./helpers/playgroundMonacoEditor"; +import { isInvertBinaryTreeCanonicalPath } from "./helpers/playgroundRoute"; /** * L5: playground opts into `instant = true` with Suspense skeleton fallback. @@ -24,12 +25,13 @@ test.describe("instant playground navigation (L5)", () => { await instant(page, async () => { await page.getByTestId("cta-to-playground").click(); - await page.waitForURL( - (url) => url.pathname === "/playground/invert-binary-tree", - { timeout: 30_000 }, - ); }); + await page.waitForURL( + (url) => isInvertBinaryTreeCanonicalPath(url.pathname), + { timeout: 30_000 }, + ); + await waitForPlaygroundMonacoEditor(page); await expect(visiblePlaygroundMonacoEditor(page)).toBeVisible(); }); diff --git a/e2e/playground-monaco-nav.spec.ts b/e2e/playground-monaco-nav.spec.ts index 714286eb..1afb7664 100644 --- a/e2e/playground-monaco-nav.spec.ts +++ b/e2e/playground-monaco-nav.spec.ts @@ -8,9 +8,12 @@ import { visiblePlaygroundMonacoEditor, waitForPlaygroundMonacoEditor, } from "./helpers/playgroundMonacoEditor"; +import { + INVERT_BINARY_TREE_CANONICAL_PATH, + isInvertBinaryTreeCanonicalPath, +} from "./helpers/playgroundRoute"; -const PLAYGROUND_PYTHON_URL = - "/playground/invert-binary-tree?view=code&language=python"; +const PLAYGROUND_PYTHON_URL = `${INVERT_BINARY_TREE_CANONICAL_PATH}?view=code&language=python`; test.describe("playground runtime navigation", () => { test.describe.configure({ mode: "serial" }); @@ -31,7 +34,7 @@ test.describe("playground runtime navigation", () => { for (let roundIndex = 0; roundIndex < 3; roundIndex += 1) { await page.getByTestId("cta-to-playground").click(); await page.waitForURL( - (url) => url.pathname === "/playground/invert-binary-tree", + (url) => isInvertBinaryTreeCanonicalPath(url.pathname), { timeout: 30_000 }, ); await waitForPlaygroundMonacoEditor(page); @@ -43,7 +46,7 @@ test.describe("playground runtime navigation", () => { await dismissCookieBannerIfVisible(page); } - await page.goto("/playground/invert-binary-tree?view=code"); + await page.goto(`${INVERT_BINARY_TREE_CANONICAL_PATH}?view=code`); await dismissCookieBannerIfVisible(page); await waitForPlaygroundMonacoEditor(page); diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 4d798b1d..6ddbcd32 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -6,7 +6,6 @@ import { vi } from "vitest"; import { LANDING_PRIMARY_PLAYGROUND_HREF } from "#/features/homePage/lib/landingPlaygroundDemos"; import { MarketingHomeView } from "#/features/homePage/ui/MarketingHomeView"; import { mockUseSearchParam } from "#/features/project/ui/ProjectBrowser/__tests__/testUtils"; -import { ProjectBrowserProvider } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; import { QuestionOfTodayDocument } from "#/graphql/generated"; import en from "#/i18n/en/index"; import type { Translation } from "#/i18n/i18n-types"; @@ -15,6 +14,8 @@ import { I18nProvider } from "#/shared/ui/providers/I18nProvider"; import { StateThemeProvider } from "#/shared/ui/providers/StateThemeProvider"; import { makeStore } from "#/store/makeStore"; +import { RuntimeDeviceHintProvider } from "#/app/locale-app/RuntimeDeviceHintContext"; + const store = makeStore(); vi.mock("next-auth/react", () => { @@ -68,13 +69,13 @@ describe("MarketingHomeView", () => { render( - - - + + + - - - + + + , { wrapper: withNextTRPC }, diff --git a/src/app/(default-locale)/(app)/daily/layout.tsx b/src/app/(default-locale)/(app)/daily/layout.tsx new file mode 100644 index 00000000..ab9e2b48 --- /dev/null +++ b/src/app/(default-locale)/(app)/daily/layout.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +import { AppChromeLayoutClient } from "#/features/appChrome/ui/AppChromeLayoutClient"; + +/** Persistent chrome for daily — Apollo prefetch stays in {@link DailyPage}. */ +export default function DefaultLocaleDailyLayout({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/src/app/(default-locale)/(app)/daily/loading.tsx b/src/app/(default-locale)/(app)/daily/loading.tsx new file mode 100644 index 00000000..bb3b63b9 --- /dev/null +++ b/src/app/(default-locale)/(app)/daily/loading.tsx @@ -0,0 +1,24 @@ +import { Box, Container, Skeleton } from "@mui/material"; + +/** Instant-nav fallback for `/daily` while the client view hydrates. */ +export default function DailyLoading() { + return ( + + + + + + + ); +} diff --git a/src/app/(default-locale)/daily/page.tsx b/src/app/(default-locale)/(app)/daily/page.tsx similarity index 100% rename from src/app/(default-locale)/daily/page.tsx rename to src/app/(default-locale)/(app)/daily/page.tsx diff --git a/src/app/(default-locale)/(app)/layout.tsx b/src/app/(default-locale)/(app)/layout.tsx new file mode 100644 index 00000000..ff479c73 --- /dev/null +++ b/src/app/(default-locale)/(app)/layout.tsx @@ -0,0 +1,8 @@ +/** App routes under `(app)` — Apollo mounts on daily/profile segment layouts only. */ +export default function DefaultLocaleAppLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/(default-locale)/(app)/playground/[[...slug]]/loading.tsx b/src/app/(default-locale)/(app)/playground/[[...slug]]/loading.tsx new file mode 100644 index 00000000..8151c695 --- /dev/null +++ b/src/app/(default-locale)/(app)/playground/[[...slug]]/loading.tsx @@ -0,0 +1,3 @@ +import { PlaygroundPageLoading } from "#/features/playground/ui/PlaygroundPageLoading"; + +export default PlaygroundPageLoading; diff --git a/src/app/(default-locale)/playground/[[...slug]]/page.tsx b/src/app/(default-locale)/(app)/playground/[[...slug]]/page.tsx similarity index 100% rename from src/app/(default-locale)/playground/[[...slug]]/page.tsx rename to src/app/(default-locale)/(app)/playground/[[...slug]]/page.tsx diff --git a/src/app/(default-locale)/(app)/playground/layout.tsx b/src/app/(default-locale)/(app)/playground/layout.tsx new file mode 100644 index 00000000..6a4fe629 --- /dev/null +++ b/src/app/(default-locale)/(app)/playground/layout.tsx @@ -0,0 +1,9 @@ +import { PlaygroundLayoutClient } from "#/features/playground/ui/PlaygroundLayoutClient"; + +export default function DefaultLocalePlaygroundLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/(default-locale)/(app)/profile/[userId]/loading.tsx b/src/app/(default-locale)/(app)/profile/[userId]/loading.tsx new file mode 100644 index 00000000..8f46fdcc --- /dev/null +++ b/src/app/(default-locale)/(app)/profile/[userId]/loading.tsx @@ -0,0 +1,5 @@ +import { ProfilePageSkeleton } from "#/features/profile/ui/ProfilePageSkeleton"; + +export default function ProfileLoading() { + return ; +} diff --git a/src/app/(default-locale)/profile/[userId]/page.tsx b/src/app/(default-locale)/(app)/profile/[userId]/page.tsx similarity index 100% rename from src/app/(default-locale)/profile/[userId]/page.tsx rename to src/app/(default-locale)/(app)/profile/[userId]/page.tsx diff --git a/src/app/(default-locale)/(app)/profile/layout.tsx b/src/app/(default-locale)/(app)/profile/layout.tsx new file mode 100644 index 00000000..cdff2994 --- /dev/null +++ b/src/app/(default-locale)/(app)/profile/layout.tsx @@ -0,0 +1,8 @@ +/** Profile route — Apollo client is created in profile page with server prefetch. */ +export default function DefaultLocaleProfileLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/(default-locale)/(marketing)/layout.tsx b/src/app/(default-locale)/(marketing)/layout.tsx new file mode 100644 index 00000000..bb56e304 --- /dev/null +++ b/src/app/(default-locale)/(marketing)/layout.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +import { MarketingLayoutClient } from "#/features/marketing/ui/MarketingLayoutClient"; + +/** Shared marketing shell (home, privacy) — instant navigations keep scroll + app bar. */ +export default function DefaultLocaleMarketingLayout({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/src/app/(default-locale)/(marketing)/loading.tsx b/src/app/(default-locale)/(marketing)/loading.tsx new file mode 100644 index 00000000..fd7743d2 --- /dev/null +++ b/src/app/(default-locale)/(marketing)/loading.tsx @@ -0,0 +1,41 @@ +import { Box, Container, Skeleton } from "@mui/material"; + +/** Instant-nav fallback for marketing home while client islands hydrate. */ +export default function MarketingHomeLoading() { + return ( + + + + + + + + + + ); +} diff --git a/src/app/(default-locale)/page.tsx b/src/app/(default-locale)/(marketing)/page.tsx similarity index 100% rename from src/app/(default-locale)/page.tsx rename to src/app/(default-locale)/(marketing)/page.tsx diff --git a/src/app/(default-locale)/(marketing)/privacy/loading.tsx b/src/app/(default-locale)/(marketing)/privacy/loading.tsx new file mode 100644 index 00000000..49dd39a4 --- /dev/null +++ b/src/app/(default-locale)/(marketing)/privacy/loading.tsx @@ -0,0 +1,15 @@ +import { Box, Container, Skeleton } from "@mui/material"; + +/** Instant-nav fallback for privacy policy while client chrome hydrates. */ +export default function PrivacyLoading() { + return ( + + + + + + + + + ); +} diff --git a/src/app/(default-locale)/privacy/page.tsx b/src/app/(default-locale)/(marketing)/privacy/page.tsx similarity index 100% rename from src/app/(default-locale)/privacy/page.tsx rename to src/app/(default-locale)/(marketing)/privacy/page.tsx diff --git a/src/app/(default-locale)/layout.tsx b/src/app/(default-locale)/layout.tsx index 5ba2fc69..ac4badda 100644 --- a/src/app/(default-locale)/layout.tsx +++ b/src/app/(default-locale)/layout.tsx @@ -2,7 +2,7 @@ import { baseLocale } from "#/i18n/i18n-util"; import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; -/** Locale shell — cached i18n + synchronous SSR device hint; session streams via Suspense (P10). */ +/** Locale shell — cached i18n + SSR device hint; session resolved in layout (P10). */ export const instant = false; /** Default-locale (`en`) public App shell at unprefixed URLs (L2). */ diff --git a/src/app/AppRootLayoutClient.tsx b/src/app/AppRootLayoutClient.tsx index ea6df9ce..9ef68413 100644 --- a/src/app/AppRootLayoutClient.tsx +++ b/src/app/AppRootLayoutClient.tsx @@ -7,7 +7,6 @@ import React, { type ReactNode } from "react"; import "symbol-observable"; import { CookieConsentRoot } from "#/features/cookieConsent/ui/CookieConsentRoot"; -import { ProjectBrowserProvider } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; import { type I18nProps } from "#/i18n/getI18nProps"; import type { Locales } from "#/i18n/i18n-types"; import { AppShellProviders } from "#/shared/ui/providers/AppShellProviders"; @@ -38,11 +37,9 @@ export const AppRootLayoutClient: React.FC = ({ - - {children} - - - + {children} + + diff --git a/src/app/[lang]/(app)/daily/layout.tsx b/src/app/[lang]/(app)/daily/layout.tsx new file mode 100644 index 00000000..90eb1644 --- /dev/null +++ b/src/app/[lang]/(app)/daily/layout.tsx @@ -0,0 +1,8 @@ +import type { ReactNode } from "react"; + +import { AppChromeLayoutClient } from "#/features/appChrome/ui/AppChromeLayoutClient"; + +/** Persistent chrome for daily — Apollo prefetch stays in {@link DailyPage}. */ +export default function LangDailyLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/app/[lang]/(app)/daily/loading.tsx b/src/app/[lang]/(app)/daily/loading.tsx new file mode 100644 index 00000000..bb3b63b9 --- /dev/null +++ b/src/app/[lang]/(app)/daily/loading.tsx @@ -0,0 +1,24 @@ +import { Box, Container, Skeleton } from "@mui/material"; + +/** Instant-nav fallback for `/daily` while the client view hydrates. */ +export default function DailyLoading() { + return ( + + + + + + + ); +} diff --git a/src/app/[lang]/daily/page.tsx b/src/app/[lang]/(app)/daily/page.tsx similarity index 100% rename from src/app/[lang]/daily/page.tsx rename to src/app/[lang]/(app)/daily/page.tsx diff --git a/src/app/[lang]/(app)/layout.tsx b/src/app/[lang]/(app)/layout.tsx new file mode 100644 index 00000000..3c7bcbb9 --- /dev/null +++ b/src/app/[lang]/(app)/layout.tsx @@ -0,0 +1,8 @@ +/** App routes under `(app)` — Apollo mounts on daily/profile segment layouts only. */ +export default function LangAppLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/[lang]/(app)/playground/[[...slug]]/loading.tsx b/src/app/[lang]/(app)/playground/[[...slug]]/loading.tsx new file mode 100644 index 00000000..8151c695 --- /dev/null +++ b/src/app/[lang]/(app)/playground/[[...slug]]/loading.tsx @@ -0,0 +1,3 @@ +import { PlaygroundPageLoading } from "#/features/playground/ui/PlaygroundPageLoading"; + +export default PlaygroundPageLoading; diff --git a/src/app/[lang]/playground/[[...slug]]/page.tsx b/src/app/[lang]/(app)/playground/[[...slug]]/page.tsx similarity index 100% rename from src/app/[lang]/playground/[[...slug]]/page.tsx rename to src/app/[lang]/(app)/playground/[[...slug]]/page.tsx diff --git a/src/app/[lang]/(app)/playground/layout.tsx b/src/app/[lang]/(app)/playground/layout.tsx new file mode 100644 index 00000000..a4c592df --- /dev/null +++ b/src/app/[lang]/(app)/playground/layout.tsx @@ -0,0 +1,9 @@ +import { PlaygroundLayoutClient } from "#/features/playground/ui/PlaygroundLayoutClient"; + +export default function LangPlaygroundLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/[lang]/(app)/profile/[userId]/loading.tsx b/src/app/[lang]/(app)/profile/[userId]/loading.tsx new file mode 100644 index 00000000..8f46fdcc --- /dev/null +++ b/src/app/[lang]/(app)/profile/[userId]/loading.tsx @@ -0,0 +1,5 @@ +import { ProfilePageSkeleton } from "#/features/profile/ui/ProfilePageSkeleton"; + +export default function ProfileLoading() { + return ; +} diff --git a/src/app/[lang]/profile/[userId]/page.tsx b/src/app/[lang]/(app)/profile/[userId]/page.tsx similarity index 100% rename from src/app/[lang]/profile/[userId]/page.tsx rename to src/app/[lang]/(app)/profile/[userId]/page.tsx diff --git a/src/app/[lang]/(app)/profile/layout.tsx b/src/app/[lang]/(app)/profile/layout.tsx new file mode 100644 index 00000000..6d0e3938 --- /dev/null +++ b/src/app/[lang]/(app)/profile/layout.tsx @@ -0,0 +1,8 @@ +/** Profile route — Apollo client is created in profile page with server prefetch. */ +export default function LangProfileLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/[lang]/(marketing)/layout.tsx b/src/app/[lang]/(marketing)/layout.tsx new file mode 100644 index 00000000..348dd203 --- /dev/null +++ b/src/app/[lang]/(marketing)/layout.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +import { MarketingLayoutClient } from "#/features/marketing/ui/MarketingLayoutClient"; + +/** Shared marketing shell (home, privacy) — instant navigations keep scroll + app bar. */ +export default function LangMarketingLayout({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/src/app/[lang]/(marketing)/loading.tsx b/src/app/[lang]/(marketing)/loading.tsx new file mode 100644 index 00000000..ad72c1ed --- /dev/null +++ b/src/app/[lang]/(marketing)/loading.tsx @@ -0,0 +1,41 @@ +import { Box, Container, Skeleton } from "@mui/material"; + +/** Instant-nav fallback for marketing home while client islands hydrate. */ +export default function LangMarketingHomeLoading() { + return ( + + + + + + + + + + ); +} diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/(marketing)/page.tsx similarity index 100% rename from src/app/[lang]/page.tsx rename to src/app/[lang]/(marketing)/page.tsx diff --git a/src/app/[lang]/(marketing)/privacy/loading.tsx b/src/app/[lang]/(marketing)/privacy/loading.tsx new file mode 100644 index 00000000..3e2fab4b --- /dev/null +++ b/src/app/[lang]/(marketing)/privacy/loading.tsx @@ -0,0 +1,15 @@ +import { Box, Container, Skeleton } from "@mui/material"; + +/** Instant-nav fallback for privacy policy while client chrome hydrates. */ +export default function LangPrivacyLoading() { + return ( + + + + + + + + + ); +} diff --git a/src/app/[lang]/privacy/page.tsx b/src/app/[lang]/(marketing)/privacy/page.tsx similarity index 100% rename from src/app/[lang]/privacy/page.tsx rename to src/app/[lang]/(marketing)/privacy/page.tsx diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index 1e411eb3..e61040dd 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,7 +1,7 @@ import { generateLangStaticParams } from "#/app/locale-app/generateLangStaticParams"; import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; -/** Locale shell — cached i18n + synchronous SSR device hint; session streams via Suspense (P10). */ +/** Locale shell — cached i18n + SSR device hint; session resolved in layout (P10). */ export const instant = false; export function generateStaticParams() { diff --git a/src/app/locale-app/ApolloHydrationProvider.tsx b/src/app/locale-app/ApolloHydrationProvider.tsx new file mode 100644 index 00000000..1c78141e --- /dev/null +++ b/src/app/locale-app/ApolloHydrationProvider.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { ApolloProvider, type NormalizedCacheObject } from "@apollo/client"; +import React, { type ReactNode, useMemo } from "react"; + +import { createApolloClient } from "#/graphql/createApolloClient"; + +type ApolloHydrationProviderProps = { + initialCache: NormalizedCacheObject | null; + children: ReactNode; +}; + +/** + * Route-scoped Apollo client with optional server-extracted cache (daily, profile). + * Playground mounts with `initialCache={null}` for {@link ProjectModal} LeetCode import only. + */ +export const ApolloHydrationProvider: React.FC< + ApolloHydrationProviderProps +> = ({ initialCache, children }) => { + const client = useMemo( + () => createApolloClient({ initialState: initialCache ?? undefined }), + [initialCache], + ); + + return {children}; +}; diff --git a/src/app/locale-app/LocaleAppPageShell.tsx b/src/app/locale-app/LocaleAppPageShell.tsx index b90d678f..ad5bf983 100644 --- a/src/app/locale-app/LocaleAppPageShell.tsx +++ b/src/app/locale-app/LocaleAppPageShell.tsx @@ -1,15 +1,6 @@ -"use client"; +import type { ReactNode } from "react"; -import React, { type ReactNode } from "react"; - -import { ProjectBrowser } from "#/features/project/ui/ProjectBrowser/ProjectBrowser"; - -/** Page tree + global overlays that require SessionProvider (inside SessionGate). */ -export const LocaleAppPageShell: React.FC<{ children: ReactNode }> = ({ - children, -}) => ( - <> - {children} - - +/** Page tree inside SessionGate (playground overlays live in playground layout). */ +export const LocaleAppPageShell = ({ children }: { children: ReactNode }) => ( + <>{children} ); diff --git a/src/app/locale-app/ProjectBrowserOverlay.tsx b/src/app/locale-app/ProjectBrowserOverlay.tsx new file mode 100644 index 00000000..1fcbd61e --- /dev/null +++ b/src/app/locale-app/ProjectBrowserOverlay.tsx @@ -0,0 +1,15 @@ +"use client"; + +import dynamic from "next/dynamic"; +import React from "react"; + +const ProjectBrowser = dynamic( + () => + import("#/features/project/ui/ProjectBrowser/ProjectBrowser").then( + (module) => ({ default: module.ProjectBrowser }), + ), + { ssr: false }, +); + +/** Global project browser modal — loaded client-only so the page shell stays a Server Component. */ +export const ProjectBrowserOverlay: React.FC = () => ; diff --git a/src/app/locale-app/pages/dailyPage.tsx b/src/app/locale-app/pages/dailyPage.tsx index e1ed140b..fee0d8c6 100644 --- a/src/app/locale-app/pages/dailyPage.tsx +++ b/src/app/locale-app/pages/dailyPage.tsx @@ -1,6 +1,8 @@ import { DailyPageView } from "#/features/homePage/ui/DailyPageView"; import type { Translation } from "#/i18n/i18n-types"; +import { getDailyInitialData } from "#/server/daily/getDailyInitialData"; +import { ApolloHydrationProvider } from "#/app/locale-app/ApolloHydrationProvider"; import { createDefaultLocaleRouteMetadata, createLangRouteMetadata, @@ -22,6 +24,12 @@ export const generateLangDailyMetadata = createLangRouteMetadata( pickDailyCopy, ); -export function DailyPage() { - return ; +export async function DailyPage() { + const initialCache = await getDailyInitialData(); + + return ( + + + + ); } diff --git a/src/app/locale-app/pages/playgroundPage.tsx b/src/app/locale-app/pages/playgroundPage.tsx index 9c520843..6b279ead 100644 --- a/src/app/locale-app/pages/playgroundPage.tsx +++ b/src/app/locale-app/pages/playgroundPage.tsx @@ -1,19 +1,26 @@ import type { Metadata } from "next"; -import React, { Suspense } from "react"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { connection } from "next/server"; +import { PlaygroundInitialDataProvider } from "#/features/playground/context/PlaygroundInitialDataContext"; import { resolvePlaygroundPageSeo } from "#/features/playground/lib/resolvePlaygroundPageSeo"; import { PlaygroundPageView } from "#/features/playground/ui/PlaygroundPageView"; import { baseLocale } from "#/i18n/i18n-util"; -import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; +import { getPlaygroundInitialData } from "#/server/playground/getPlaygroundInitialData"; +import { resolveCanonicalPlaygroundRedirect } from "#/server/playground/resolveCanonicalPlaygroundRedirect"; +import { serializePlaygroundInitialData } from "#/server/playground/serializePlaygroundInitialData"; +import { APP_ROUTER_SSR_DEVICE_TYPE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; +import { LAST_PLAYGROUND_PATH_COOKIE } from "#/shared/lib/playgroundLastPathCookie"; +import { playgroundBasePathForLocale } from "#/shared/lib/playgroundRoute"; +import { parseSsrDeviceTypeHeader } from "#/shared/lib/ssrDevice"; import { publicAppMetadata } from "#/app/locale-app/publicAppMetadata"; import { resolveLangParamSync } from "#/app/locale-app/resolveLangParam"; -/** Playground shell — instant with Suspense fallback skeleton (L5). */ +/** Playground — instant shell; public data prefetched on server; fallback via loading.tsx. */ export const instant = true; -const PlaygroundFallback: React.FC = () => ; - export async function generateDefaultLocalePlaygroundMetadata({ params, }: { @@ -60,10 +67,55 @@ export async function generateLangPlaygroundMetadata({ }); } -export function PlaygroundPage() { +type PlaygroundPageProps = { + params: Promise<{ slug?: string[]; lang?: string }>; + searchParams: Promise<{ view?: string }>; +}; + +export async function PlaygroundPage({ + params, + searchParams, +}: PlaygroundPageProps) { + await connection(); + const { slug, lang: langParam } = await params; + const { view: viewParam } = await searchParams; + const locale = langParam + ? (resolveLangParamSync(langParam) ?? baseLocale) + : baseLocale; + const basePath = playgroundBasePathForLocale(locale); + const cookieStore = await cookies(); + const rawLastPathCookie = + cookieStore.get(LAST_PLAYGROUND_PATH_COOKIE)?.value ?? null; + const lastPathCookie = rawLastPathCookie + ? decodeURIComponent(rawLastPathCookie) + : null; + + const headerList = await headers(); + const ssrDeviceType = + parseSsrDeviceTypeHeader( + headerList.get(APP_ROUTER_SSR_DEVICE_TYPE_HEADER), + ) ?? "desktop"; + + const redirectPath = await resolveCanonicalPlaygroundRedirect({ + basePath, + slug: slug ?? [], + lastPathCookie, + ssrDeviceType, + viewParam, + }); + + if (redirectPath) { + redirect(redirectPath); + } + + const [projectSlug, caseSlug, solutionSlug] = slug ?? []; + const initialData = serializePlaygroundInitialData( + await getPlaygroundInitialData(projectSlug, caseSlug, solutionSlug), + ); + return ( - }> + - + ); } diff --git a/src/app/locale-app/pages/privacyPage.tsx b/src/app/locale-app/pages/privacyPage.tsx index e487a4cf..929ca9d3 100644 --- a/src/app/locale-app/pages/privacyPage.tsx +++ b/src/app/locale-app/pages/privacyPage.tsx @@ -1,12 +1,16 @@ -import { PrivacyPageView } from "#/features/privacy/ui/PrivacyPageView"; -import type { Translation } from "#/i18n/i18n-types"; +import { PrivacyPageContent } from "#/features/privacy/ui/PrivacyPageContent"; +import { createTranslationFunctions } from "#/i18n/createTranslationFunctions"; +import type { Locales, Translation } from "#/i18n/i18n-types"; +import { baseLocale } from "#/i18n/i18n-util"; +import { loadI18nForLocale } from "#/i18n/loadI18nForLocale"; import { createDefaultLocaleRouteMetadata, createLangRouteMetadata, } from "#/app/locale-app/createLocaleRouteMetadata"; +import { resolveLangParamSync } from "#/app/locale-app/resolveLangParam"; -/** Marketing privacy — instant client navigations to sibling routes (L5). */ +/** Marketing privacy — instant client navigations; body is server-rendered (RSC). */ export const instant = true; const pickPrivacyCopy = (translation: Translation) => ({ @@ -22,6 +26,32 @@ export const generateLangPrivacyMetadata = createLangRouteMetadata( pickPrivacyCopy, ); -export function PrivacyPage() { - return ; +type PrivacyPageProps = { + params?: Promise<{ lang?: string }>; +}; + +async function resolvePrivacyLocale( + params?: Promise<{ lang?: string }>, +): Promise { + if (!params) { + return baseLocale; + } + const { lang: langParam } = await params; + if (!langParam) { + return baseLocale; + } + return resolveLangParamSync(langParam) ?? baseLocale; +} + +export async function PrivacyPage({ params }: PrivacyPageProps = {}) { + const locale = await resolvePrivacyLocale(params); + const { translations } = await loadI18nForLocale(locale); + const translation = translations[locale]; + if (!translation) { + throw new Error(`Missing translations for locale: ${locale}`); + } + const LL = createTranslationFunctions(locale, translation); + const homePath = locale === baseLocale ? "/" : `/${locale}`; + + return ; } diff --git a/src/app/locale-app/pages/profilePage.tsx b/src/app/locale-app/pages/profilePage.tsx index 9b96a761..fa9b5f87 100644 --- a/src/app/locale-app/pages/profilePage.tsx +++ b/src/app/locale-app/pages/profilePage.tsx @@ -1,15 +1,21 @@ import type { Metadata } from "next"; +import { getServerSession } from "next-auth"; +import { cookies } from "next/headers"; +import { notFound } from "next/navigation"; import { Suspense } from "react"; -import { ProfilePageGate } from "#/features/profile/ui/ProfilePageGate"; import { ProfilePageSkeleton } from "#/features/profile/ui/ProfilePageSkeleton"; +import { ProfilePageView } from "#/features/profile/ui/ProfilePageView"; import type { Locales, Translation } from "#/i18n/i18n-types"; import { baseLocale } from "#/i18n/i18n-util"; +import { authOptions } from "#/server/auth/authOptions"; +import { getProfileInitialData } from "#/server/profile/getProfileInitialData"; +import { ApolloHydrationProvider } from "#/app/locale-app/ApolloHydrationProvider"; import { publicRouteMetadataForLocale } from "#/app/locale-app/createLocaleRouteMetadata"; import { resolveLangParamSync } from "#/app/locale-app/resolveLangParam"; -/** Profile — instant shell with Suspense fallback; user data client-fetched (P10). */ +/** Profile — instant shell with Suspense fallback; user data prefetched when possible. */ export const instant = true; const pickProfileCopy = (translation: Translation) => ({ @@ -60,10 +66,39 @@ type ProfilePageProps = { params: Promise<{ userId: string; lang?: string }>; }; +/** Server gate: validate `userId` and prefetch LeetCode profile when session allows. */ +async function ProfilePageContent({ + params, +}: { + params: Promise<{ userId: string }>; +}) { + const { userId } = await params; + if (!userId.trim()) { + notFound(); + } + + const [session, cookieStore] = await Promise.all([ + getServerSession(authOptions), + cookies(), + ]); + const leetCodeUsername = session?.user?.leetCodeUsername; + const leetCodeSession = cookieStore.get("LEETCODE_SESSION")?.value ?? null; + const initialCache = await getProfileInitialData( + leetCodeUsername, + leetCodeSession, + ); + + return ( + + + + ); +} + export function ProfilePage({ params }: ProfilePageProps) { return ( }> - + ); } diff --git a/src/features/appBar/constants.ts b/src/features/appBar/constants.ts index 308e1286..1d56c59a 100644 --- a/src/features/appBar/constants.ts +++ b/src/features/appBar/constants.ts @@ -1,2 +1,5 @@ /** Height of the app bar when in mobile playground layout. */ export const MOBILE_APPBAR_HEIGHT = 48; + +/** Dense desktop toolbar height on playground (`MainAppBar` + split layout). */ +export const PLAYGROUND_DESKTOP_APP_BAR_HEIGHT = 56; diff --git a/src/features/appBar/model/appBarSlice.ts b/src/features/appBar/model/appBarSlice.ts index cb34b121..61ac3db7 100644 --- a/src/features/appBar/model/appBarSlice.ts +++ b/src/features/appBar/model/appBarSlice.ts @@ -30,10 +30,15 @@ export const appBarSlice = createSlice({ name: "APP_BAR", initialState, reducers: { - setIsScrolled: (state, action: PayloadAction) => ({ - ...state, - isScrolled: action.payload, - }), + setIsScrolled: (state, action: PayloadAction) => { + if (state.isScrolled === action.payload) { + return state; + } + return { + ...state, + isScrolled: action.payload, + }; + }, setIsLightMode: (state, action: PayloadAction) => ({ ...state, isLightMode: action.payload, diff --git a/src/features/appBar/ui/MainAppBar.tsx b/src/features/appBar/ui/MainAppBar.tsx index 1266399e..a5b4968b 100644 --- a/src/features/appBar/ui/MainAppBar.tsx +++ b/src/features/appBar/ui/MainAppBar.tsx @@ -27,6 +27,7 @@ import { import { signIn, useSession } from "next-auth/react"; import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useSnackbar } from "notistack"; import React, { type MouseEvent, useState } from "react"; @@ -34,12 +35,12 @@ import { MOBILE_APPBAR_HEIGHT } from "#/features/appBar/constants"; import { selectIsAppBarScrolled } from "#/features/appBar/model/appBarSlice"; import { SidePanel } from "#/features/menuSidePanel/ui/SidePanel"; import { useMobilePlaygroundView } from "#/features/playground/hooks/useMobilePlaygroundView"; -import { useProjectBrowserContext } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; +import { useOptionalProjectBrowserContext } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; import { appFontStackDisplay } from "#/shared/fonts/fontVariables"; import { useI18nContext, useRoutePathname } from "#/shared/hooks"; import { useProfileImageUploader } from "#/shared/hooks"; import { useHasMounted } from "#/shared/hooks/useHasMounted"; -import { useMobileLayout } from "#/shared/hooks/useMobileLayout"; import { getImageUrl } from "#/shared/lib"; import { useAppSelector } from "#/store/hooks"; @@ -99,22 +100,23 @@ export const MainAppBar: React.FC = ({ }) => { const currentPath = useRoutePathname(); const theme = useTheme(); - const isMobileLayout = useMobileLayout(); + const isPlaygroundMobileLayout = usePlaygroundMobileLayout(); const useCompactNav = useMediaQuery(theme.breakpoints.down("lg")); const { LL } = useI18nContext(); const { enqueueSnackbar } = useSnackbar(); const hasMounted = useHasMounted(); + const router = useRouter(); const [isSidePanelOpen, setIsSidePanelOpen] = useState(false); const [anchorElNav, setAnchorElNav] = useState(null); - const { openBrowser } = useProjectBrowserContext(); + const projectBrowser = useOptionalProjectBrowserContext(); const { currentView } = useMobilePlaygroundView(); const isScrolled = useAppSelector(selectIsAppBarScrolled); const session = useSession(); const isPlayground = currentPath.startsWith("/playground"); - const useMobilePlayground = isMobileLayout && isPlayground; + const useMobilePlayground = isPlayground && isPlaygroundMobileLayout; const pages = [ { @@ -153,6 +155,15 @@ export const MainAppBar: React.FC = ({ setIsSidePanelOpen(true); }; + const handleOpenProjectBrowser = () => { + if (projectBrowser) { + projectBrowser.openBrowser(); + return; + } + + router.push("/playground?view=browse"); + }; + const toolbarHeight = useMobilePlayground ? MOBILE_APPBAR_HEIGHT : 56; const isCompact = useCompactNav; @@ -298,7 +309,7 @@ export const MainAppBar: React.FC = ({ diff --git a/src/features/appBar/ui/__tests__/MainAppBar.test.tsx b/src/features/appBar/ui/__tests__/MainAppBar.test.tsx index 9355e474..7a14748b 100644 --- a/src/features/appBar/ui/__tests__/MainAppBar.test.tsx +++ b/src/features/appBar/ui/__tests__/MainAppBar.test.tsx @@ -46,6 +46,10 @@ vi.mock("#/shared/hooks/useMobileLayout", () => ({ useMobileLayout: () => true, })); +vi.mock("#/features/playground/hooks/usePlaygroundMobileLayout", () => ({ + usePlaygroundMobileLayout: () => true, +})); + vi.mock("#/features/playground/hooks/useMobilePlaygroundView", () => ({ useMobilePlaygroundView: () => ({ currentView: "code" as const, @@ -54,7 +58,7 @@ vi.mock("#/features/playground/hooks/useMobilePlaygroundView", () => ({ const mockOpenBrowser = vi.fn(); vi.mock("#/features/project/ui/ProjectBrowser/ProjectBrowserContext", () => ({ - useProjectBrowserContext: () => ({ + useOptionalProjectBrowserContext: () => ({ openBrowser: mockOpenBrowser, }), })); diff --git a/src/features/appChrome/ui/AppChromeLayoutClient.tsx b/src/features/appChrome/ui/AppChromeLayoutClient.tsx new file mode 100644 index 00000000..e6544fef --- /dev/null +++ b/src/features/appChrome/ui/AppChromeLayoutClient.tsx @@ -0,0 +1,17 @@ +"use client"; + +import React, { type ReactNode } from "react"; + +import { MainLayout } from "#/shared/ui/templates/MainLayout"; + +type AppChromeLayoutClientProps = { + children: ReactNode; +}; + +/** + * Persistent MainLayout for app routes that need marketing-style chrome + * (daily) without remounting scroll container on instant navigations. + */ +export const AppChromeLayoutClient: React.FC = ({ + children, +}) => {children}; diff --git a/src/features/codeRunner/hooks/usePythonCodeRunner.tsx b/src/features/codeRunner/hooks/usePythonCodeRunner.tsx index bf3a9953..a5af1972 100644 --- a/src/features/codeRunner/hooks/usePythonCodeRunner.tsx +++ b/src/features/codeRunner/hooks/usePythonCodeRunner.tsx @@ -1,64 +1,11 @@ import { useMutation } from "@tanstack/react-query"; -import { useCallback, useEffect } from "react"; - -import { useAppDispatch } from "#/store/hooks"; +import { useCallback } from "react"; import type { SerializedPythonArg } from "../lib/createPythonRuntimeArgs"; import { pythonRunner } from "../lib/pythonRunner"; -import { pyodideSlice } from "../model/pyodideSlice"; import type { ExecutionResult } from "./useCodeExecution"; -import { usePyodideProgressSnackbar } from "./usePyodideProgressSnackbar"; - -/** Delay after 100% so the progress bar animation completes before snackbar closes. */ -const PROGRESS_COMPLETE_DELAY_MS = 400; export const usePythonCodeRunner = () => { - const dispatch = useAppDispatch(); - - usePyodideProgressSnackbar(); - - // Preload Pyodide worker when the hook mounts (user entered Python page). - // Progress is dispatched to Redux; usePyodideProgressSnackbar shows the snackbar. - useEffect(() => { - if (pythonRunner.isReady) return; - - let cancelled = false; - - dispatch( - pyodideSlice.actions.setProgress({ value: 0, stage: "Starting…" }), - ); - - let completeTimeoutId: ReturnType | null = null; - - pythonRunner - .init({ - onProgress: (value, stage) => { - if (!cancelled) { - dispatch(pyodideSlice.actions.setProgress({ value, stage })); - } - }, - }) - .catch(() => undefined) - .finally(() => { - if (cancelled) return; - - completeTimeoutId = setTimeout(() => { - completeTimeoutId = null; - if (!cancelled) { - dispatch(pyodideSlice.actions.clearProgress()); - } - }, PROGRESS_COMPLETE_DELAY_MS); - }); - - return () => { - cancelled = true; - if (completeTimeoutId !== null) { - clearTimeout(completeTimeoutId); - } - dispatch(pyodideSlice.actions.clearProgress()); - }; - }, [dispatch]); - const { mutateAsync: executePythonCode, isPending } = useMutation({ mutationFn: async ({ codeInput, diff --git a/src/features/codeRunner/ui/CodePanel.tsx b/src/features/codeRunner/ui/CodePanel.tsx index 137ade26..807c4faa 100644 --- a/src/features/codeRunner/ui/CodePanel.tsx +++ b/src/features/codeRunner/ui/CodePanel.tsx @@ -49,6 +49,7 @@ import { EditorStateIcon, } from "#/features/codeRunner/ui/EditorStateIcon"; import { SolutionSelectBar } from "#/features/codeRunner/ui/SolutionSelectBar"; +import { usePlaygroundInitialData } from "#/features/playground/context/PlaygroundInitialDataContext"; import { projectSlice, selectIsEditable, @@ -121,6 +122,7 @@ export const CodePanel: React.FC = ({ const saveTimeoutControllerRef = useRef(createLatestOnlyTimeoutController()); const { projectSlug = "", solutionSlug = "" } = usePlaygroundSlugs(); + const serverInitialData = usePlaygroundInitialData(); const isEditable = useAppSelector(selectIsEditable); const isEditingNodes = useAppSelector(selectIsEditingNodes); const error = useAppSelector(selectCallstackError); @@ -145,6 +147,11 @@ export const CodePanel: React.FC = ({ }, { enabled: Boolean(selectedProject.data?.id && solutionSlug), + initialData: + serverInitialData?.solutionBySlug?.slug === solutionSlug && + serverInitialData.projectBySlug?.id === selectedProject.data?.id + ? serverInitialData.solutionBySlug + : undefined, }, ); diff --git a/src/features/codeRunner/ui/SolutionSelectBar.tsx b/src/features/codeRunner/ui/SolutionSelectBar.tsx index fa1bc932..4a9bc4e0 100644 --- a/src/features/codeRunner/ui/SolutionSelectBar.tsx +++ b/src/features/codeRunner/ui/SolutionSelectBar.tsx @@ -2,7 +2,7 @@ import type { OnDragEndResponder } from "@hello-pangea/dnd"; import type { StackProps } from "@mui/material"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { SolutionModal } from "#/features/codeRunner/ui/SolutionModal"; import { selectIsEditable } from "#/features/project/model/projectSlice"; @@ -14,7 +14,7 @@ import { api } from "#/shared/api"; import type { RouterOutputs } from "#/shared/api"; import { usePlaygroundSlugs } from "#/shared/hooks"; import { useI18nContext } from "#/shared/hooks"; -import { useAppDispatch, useAppSelector } from "#/store/hooks"; +import { useAppSelector } from "#/store/hooks"; type SolutionBrief = Pick< PlaygroundSolution, @@ -35,14 +35,11 @@ export const SolutionSelectBar: React.FC = ({ const { projectSlug = "", - caseSlug = "", solutionSlug = "", setSolution, } = usePlaygroundSlugs(); const solutions = selectedProject.data?.solutions; - const dispatch = useAppDispatch(); - const trpcUtils = api.useUtils(); const updateSolutionsCache = (solutions: SolutionBrief[]) => { @@ -94,16 +91,6 @@ export const SolutionSelectBar: React.FC = ({ const isEditable = useAppSelector(selectIsEditable); - useEffect(() => { - if (solutionSlug || !selectedProject.data || !caseSlug) return; - - const firstSolutionSlug = selectedProject.data.solutions[0]?.slug; - - if (firstSolutionSlug) { - setSolution(firstSolutionSlug); - } - }, [solutionSlug, selectedProject.data, dispatch, setSolution, caseSlug]); - const handleSolutionClick = (solution: SolutionBrief) => { void setSolution(solution.slug); }; diff --git a/src/features/homePage/ui/DailyPageView.tsx b/src/features/homePage/ui/DailyPageView.tsx index 8ed7c065..bcd4395f 100644 --- a/src/features/homePage/ui/DailyPageView.tsx +++ b/src/features/homePage/ui/DailyPageView.tsx @@ -16,7 +16,6 @@ import { useDailyQuestionData } from "#/api"; import { DailyProblem } from "#/features/homePage/ui/DailyProblem/DailyProblem"; import { QuestionSummary } from "#/features/homePage/ui/QuestionSummary"; import { useI18nContext } from "#/shared/hooks"; -import { MainLayout } from "#/shared/ui/templates/MainLayout"; /** Daily problem page content. Shared by Pages `/daily` and App Router pilot. */ export const DailyPageView: React.FC = () => { @@ -26,88 +25,86 @@ export const DailyPageView: React.FC = () => { const isMediumScreen = useMediaQuery(theme.breakpoints.between("sm", "lg")); return ( - - + - - - + + {LL.HOME_DAILY_SECTION_TITLE()} + + + {LL.HOME_DAILY_SECTION_LEAD()}{" "} + - {LL.HOME_DAILY_SECTION_TITLE()} - - + + + + {questionDataQuery.error ? ( + + {LL.HOME_DAILY_QUESTION_ERROR()} + + ) : null} + + + + - {LL.HOME_DAILY_SECTION_LEAD()}{" "} - - LeetCode - - - - - {questionDataQuery.error ? ( - - {LL.HOME_DAILY_QUESTION_ERROR()} - - ) : null} - - - - - - - - + /> + + + - - - + + + ); }; diff --git a/src/features/homePage/ui/MarketingHomeView.tsx b/src/features/homePage/ui/MarketingHomeView.tsx index 19e20a81..3dd17622 100644 --- a/src/features/homePage/ui/MarketingHomeView.tsx +++ b/src/features/homePage/ui/MarketingHomeView.tsx @@ -3,27 +3,21 @@ /** * Marketing home UI. Public `/` and `app/[lang]` reuse this. */ -import { useState } from "react"; - import { HomeLandingFaq } from "#/features/homePage/ui/landing/HomeLandingFaq"; import { HomeLandingHero } from "#/features/homePage/ui/landing/HomeLandingHero"; import { HomeLandingSections } from "#/features/homePage/ui/landing/HomeLandingSections"; +import { useMarketingScrollViewport } from "#/features/marketing/context/MarketingScrollContext"; import { useI18nContext } from "#/shared/hooks"; -import { MainLayout } from "#/shared/ui/templates/MainLayout"; export const MarketingHomeView: React.FC = () => { const { LL } = useI18nContext(); - const [pageScrollViewport, setPageScrollViewport] = - useState(null); + const pageScrollViewport = useMarketingScrollViewport(); return ( - + <> - + ); }; diff --git a/src/features/marketing/context/MarketingScrollContext.tsx b/src/features/marketing/context/MarketingScrollContext.tsx new file mode 100644 index 00000000..c7c5beed --- /dev/null +++ b/src/features/marketing/context/MarketingScrollContext.tsx @@ -0,0 +1,55 @@ +"use client"; + +import React, { + createContext, + type ReactNode, + useContext, + useMemo, + useState, +} from "react"; + +type MarketingScrollContextValue = { + pageScrollViewport: HTMLDivElement | null; + setPageScrollViewport: (viewport: HTMLDivElement | null) => void; +}; + +const MarketingScrollContext = + createContext(null); + +export const MarketingScrollProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [pageScrollViewport, setPageScrollViewport] = + useState(null); + + const value = useMemo( + () => ({ + pageScrollViewport, + setPageScrollViewport, + }), + [pageScrollViewport], + ); + + return ( + + {children} + + ); +}; + +export const useMarketingScrollViewport = (): HTMLDivElement | null => { + const context = useContext(MarketingScrollContext); + return context?.pageScrollViewport ?? null; +}; + +export const useMarketingScrollViewportRef = (): (( + viewport: HTMLDivElement | null, +) => void) => { + const context = useContext(MarketingScrollContext); + if (!context) { + throw new Error( + "useMarketingScrollViewportRef must be used within MarketingScrollProvider", + ); + } + return context.setPageScrollViewport; +}; diff --git a/src/features/marketing/ui/MarketingLayoutClient.tsx b/src/features/marketing/ui/MarketingLayoutClient.tsx new file mode 100644 index 00000000..366483a6 --- /dev/null +++ b/src/features/marketing/ui/MarketingLayoutClient.tsx @@ -0,0 +1,62 @@ +"use client"; + +import React, { type ReactNode, useEffect } from "react"; + +import { appBarSlice } from "#/features/appBar/model/appBarSlice"; +import { + MarketingScrollProvider, + useMarketingScrollViewport, + useMarketingScrollViewportRef, +} from "#/features/marketing/context/MarketingScrollContext"; +import { useRoutePathname } from "#/shared/hooks"; +import { MainLayout } from "#/shared/ui/templates/MainLayout"; +import { useAppDispatch } from "#/store/hooks"; + +type MarketingLayoutChromeProps = { + children: ReactNode; +}; + +/** + * Persistent marketing chrome — survives instant navigations between `/` and + * `/privacy` so {@link PageScrollContainer} keeps driving app bar scroll state. + */ +const MarketingLayoutChrome: React.FC = ({ + children, +}) => { + const dispatch = useAppDispatch(); + const pathname = useRoutePathname(); + const pageScrollViewport = useMarketingScrollViewport(); + const setPageScrollViewport = useMarketingScrollViewportRef(); + + // Reset scroll position and app bar state when switching marketing routes. + useEffect(() => { + if (!pageScrollViewport) { + dispatch(appBarSlice.actions.setIsScrolled(false)); + return; + } + + pageScrollViewport.scrollTo(0, 0); + dispatch(appBarSlice.actions.setIsScrolled(false)); + }, [dispatch, pageScrollViewport, pathname]); + + return ( + + {children} + + ); +}; + +type MarketingLayoutClientProps = { + children: ReactNode; +}; + +export const MarketingLayoutClient: React.FC = ({ + children, +}) => ( + + {children} + +); diff --git a/src/features/playground/context/PlaygroundInitialDataContext.tsx b/src/features/playground/context/PlaygroundInitialDataContext.tsx new file mode 100644 index 00000000..a8efeadd --- /dev/null +++ b/src/features/playground/context/PlaygroundInitialDataContext.tsx @@ -0,0 +1,24 @@ +"use client"; + +import React, { createContext, type ReactNode, useContext } from "react"; + +import type { PlaygroundInitialData } from "#/server/playground/getPlaygroundInitialData"; + +const PlaygroundInitialDataContext = + createContext(null); + +type PlaygroundInitialDataProviderProps = { + initialData: PlaygroundInitialData; + children: ReactNode; +}; + +export const PlaygroundInitialDataProvider: React.FC< + PlaygroundInitialDataProviderProps +> = ({ initialData, children }) => ( + + {children} + +); + +export const usePlaygroundInitialData = (): PlaygroundInitialData | null => + useContext(PlaygroundInitialDataContext); diff --git a/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx b/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx new file mode 100644 index 00000000..35d32600 --- /dev/null +++ b/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx @@ -0,0 +1,96 @@ +import { renderHook } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { usePlaygroundPanelsReady } from "#/features/playground/hooks/usePlaygroundPanelsReady"; +import { projectSlice } from "#/features/project/model/projectSlice"; +import { makeStore } from "#/store/makeStore"; + +const mockUsePlaygroundMobileLayout = vi.fn(() => false); +const mockUsePlaygroundRoute = vi.fn(() => ({ + basePath: "/playground", + slug: ["two-sum"], + pathname: "/playground/two-sum", + navigateTo: vi.fn(), +})); + +vi.mock("#/features/playground/hooks/usePlaygroundMobileLayout", () => ({ + usePlaygroundMobileLayout: () => mockUsePlaygroundMobileLayout(), +})); + +vi.mock("#/shared/hooks/usePlaygroundRoute", () => ({ + usePlaygroundRoute: () => mockUsePlaygroundRoute(), +})); + +vi.mock( + "#/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout", + () => ({ + prefetchSplitPanelsLayout: vi.fn(() => Promise.resolve({})), + }), +); + +describe("usePlaygroundPanelsReady", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUsePlaygroundMobileLayout.mockReturnValue(false); + mockUsePlaygroundRoute.mockReturnValue({ + basePath: "/playground", + slug: ["two-sum"], + pathname: "/playground/two-sum", + navigateTo: vi.fn(), + }); + }); + + it("returns false on desktop until split layout loads and project is initialized", async () => { + const store = makeStore(); + + const { result } = renderHook(() => usePlaygroundPanelsReady(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toBe(false); + + store.dispatch(projectSlice.actions.loadFinish()); + + await vi.waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it("returns true on mobile browse view without a project slug", () => { + mockUsePlaygroundMobileLayout.mockReturnValue(true); + mockUsePlaygroundRoute.mockReturnValue({ + basePath: "/playground", + slug: [], + pathname: "/playground", + navigateTo: vi.fn(), + }); + + const store = makeStore(); + + const { result } = renderHook(() => usePlaygroundPanelsReady(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toBe(true); + }); + + it("returns true on desktop bare /playground after split layout loads", async () => { + mockUsePlaygroundRoute.mockReturnValue({ + basePath: "/playground", + slug: [], + pathname: "/playground", + navigateTo: vi.fn(), + }); + + const store = makeStore(); + + const { result } = renderHook(() => usePlaygroundPanelsReady(), { + wrapper: ({ children }) => {children}, + }); + + await vi.waitFor(() => { + expect(result.current).toBe(true); + }); + }); +}); diff --git a/src/features/playground/hooks/__tests__/usePlaygroundSlugLoadingSync.test.tsx b/src/features/playground/hooks/__tests__/usePlaygroundSlugLoadingSync.test.tsx new file mode 100644 index 00000000..a8e16b64 --- /dev/null +++ b/src/features/playground/hooks/__tests__/usePlaygroundSlugLoadingSync.test.tsx @@ -0,0 +1,81 @@ +import { renderHook } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { usePlaygroundSlugLoadingSync } from "#/features/playground/hooks/usePlaygroundSlugLoadingSync"; +import { projectSlice } from "#/features/project/model/projectSlice"; +import { makeStore } from "#/store/makeStore"; + +const mockUsePlaygroundRoute = vi.fn(() => ({ + basePath: "/playground", + slug: ["two-sum", "case-1", "solution-1"], + pathname: "/playground/two-sum/case-1/solution-1", + navigateTo: vi.fn(), +})); + +const mockServerPrefetchMatchesRoute = vi.fn( + (_initialData: unknown, _slug: unknown) => false, +); + +vi.mock("#/shared/hooks/usePlaygroundRoute", () => ({ + usePlaygroundRoute: () => mockUsePlaygroundRoute(), +})); + +vi.mock("#/features/playground/lib/serverPrefetchMatchesRoute", () => ({ + serverPrefetchMatchesRoute: (initialData: unknown, slug: unknown) => + mockServerPrefetchMatchesRoute(initialData, slug), +})); + +vi.mock("#/features/playground/context/PlaygroundInitialDataContext", () => ({ + usePlaygroundInitialData: () => null, +})); + +describe("usePlaygroundSlugLoadingSync", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUsePlaygroundRoute.mockReturnValue({ + basePath: "/playground", + slug: ["two-sum", "case-1", "solution-1"], + pathname: "/playground/two-sum/case-1/solution-1", + navigateTo: vi.fn(), + }); + mockServerPrefetchMatchesRoute.mockReturnValue(false); + }); + + it("skips loadStart on first render when server prefetch matches the URL", () => { + mockServerPrefetchMatchesRoute.mockReturnValue(true); + + const store = makeStore(); + store.dispatch(projectSlice.actions.loadFinish()); + + renderHook(() => usePlaygroundSlugLoadingSync(), { + wrapper: ({ children }) => {children}, + }); + + expect(store.getState().project.isInitialized).toBe(true); + }); + + it("dispatches loadStart when slug segments change", () => { + const store = makeStore(); + + const { rerender } = renderHook(() => usePlaygroundSlugLoadingSync(), { + wrapper: ({ children }) => {children}, + }); + + expect(store.getState().project.isInitialized).toBe(false); + + store.dispatch(projectSlice.actions.loadFinish()); + expect(store.getState().project.isInitialized).toBe(true); + + mockUsePlaygroundRoute.mockReturnValue({ + basePath: "/playground", + slug: ["three-sum"], + pathname: "/playground/three-sum", + navigateTo: vi.fn(), + }); + + rerender(); + + expect(store.getState().project.isInitialized).toBe(false); + }); +}); diff --git a/src/features/playground/hooks/useBarePlaygroundBrowseLanding.ts b/src/features/playground/hooks/useBarePlaygroundBrowseLanding.ts new file mode 100644 index 00000000..b8d95d86 --- /dev/null +++ b/src/features/playground/hooks/useBarePlaygroundBrowseLanding.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; +import { useOptionalProjectBrowserContext } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; +import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; + +/** + * Desktop bare `/playground` is an indexable landing — open the project browser + * so users are not stuck on an empty split layout. + */ +export const useBarePlaygroundBrowseLanding = (): void => { + const isMobile = usePlaygroundMobileLayout(); + const route = usePlaygroundRoute(); + const projectBrowser = useOptionalProjectBrowserContext(); + const openedForPathRef = useRef(null); + + const routePath = route?.pathname ?? ""; + const projectSlug = route?.slug[0] ?? ""; + + useEffect(() => { + openedForPathRef.current = null; + }, [routePath]); + + useEffect(() => { + if (!route || isMobile || projectSlug || !projectBrowser) { + return; + } + + if (openedForPathRef.current === routePath) { + return; + } + + openedForPathRef.current = routePath; + projectBrowser.openBrowser(); + }, [isMobile, projectBrowser, projectSlug, route, routePath]); +}; diff --git a/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts b/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts new file mode 100644 index 00000000..7a8d3f7e --- /dev/null +++ b/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts @@ -0,0 +1,84 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useEffect, useRef } from "react"; + +import { usePlaygroundInitialData } from "#/features/playground/context/PlaygroundInitialDataContext"; +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; +import { api } from "#/shared/api"; +import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; +import { appendPlaygroundMobileViewQuery } from "#/shared/lib/appendPlaygroundMobileViewQuery"; +import { + buildCanonicalPlaygroundSlug, + playgroundSlugKey, +} from "#/shared/lib/buildCanonicalPlaygroundSlug"; +import { buildPlaygroundPath } from "#/shared/lib/playgroundRoute"; + +/** + * Mirrors server canonical redirects for client navigations (instant nav, ). + * Server `redirect()` does not always update the browser URL during soft navigations. + */ +export const useClientCanonicalPlaygroundRedirect = (): void => { + const route = usePlaygroundRoute(); + const searchParams = useSearchParams(); + const isMobile = usePlaygroundMobileLayout(); + const serverInitialData = usePlaygroundInitialData(); + const redirectingRef = useRef(false); + + const routeProjectSlug = route?.slug[0] ?? ""; + const viewParam = searchParams?.get("view") ?? null; + const routePath = route?.pathname ?? ""; + + const projectQuery = api.project.getBySlug.useQuery(routeProjectSlug, { + enabled: Boolean(route && routeProjectSlug), + initialData: + serverInitialData?.projectBySlug?.slug === routeProjectSlug + ? serverInitialData.projectBySlug + : undefined, + }); + + useEffect(() => { + redirectingRef.current = false; + }, [routePath]); + + useEffect(() => { + if ( + !route || + !routeProjectSlug || + !projectQuery.data || + redirectingRef.current + ) { + return; + } + + const canonicalSlug = buildCanonicalPlaygroundSlug( + projectQuery.data, + route.slug[1], + route.slug[2], + ); + + if (playgroundSlugKey(route.slug) === playgroundSlugKey(canonicalSlug)) { + redirectingRef.current = false; + return; + } + + redirectingRef.current = true; + const canonicalPath = buildPlaygroundPath(route.basePath, canonicalSlug); + const targetPath = appendPlaygroundMobileViewQuery(canonicalPath, { + isMobile, + hasViewParam: Boolean(viewParam), + hasCanonicalSlug: canonicalSlug.length > 0, + }); + + route.navigateTo(targetPath, { + replace: true, + }); + }, [ + isMobile, + projectQuery.data, + route, + routeProjectSlug, + routePath, + viewParam, + ]); +}; diff --git a/src/features/playground/hooks/usePlaygroundMobileLayout.ts b/src/features/playground/hooks/usePlaygroundMobileLayout.ts new file mode 100644 index 00000000..10ccb645 --- /dev/null +++ b/src/features/playground/hooks/usePlaygroundMobileLayout.ts @@ -0,0 +1,22 @@ +"use client"; + +import { type Theme, useMediaQuery } from "@mui/material"; + +import { useHasMounted } from "#/shared/hooks/useHasMounted"; + +import { useRuntimeDeviceHint } from "#/app/locale-app/RuntimeDeviceHintContext"; + +/** Playground layout mode — SSR device hint until mount, then live breakpoint. */ +export const usePlaygroundMobileLayout = (): boolean => { + const { ssrDeviceType } = useRuntimeDeviceHint(); + const hasMounted = useHasMounted(); + const matchesMobile = useMediaQuery((theme: Theme) => + theme.breakpoints.down("sm"), + ); + + if (!hasMounted) { + return ssrDeviceType === "mobile"; + } + + return matchesMobile; +}; diff --git a/src/features/playground/hooks/usePlaygroundPanelsReady.ts b/src/features/playground/hooks/usePlaygroundPanelsReady.ts new file mode 100644 index 00000000..3044a555 --- /dev/null +++ b/src/features/playground/hooks/usePlaygroundPanelsReady.ts @@ -0,0 +1,64 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; +import { selectIsInitialized } from "#/features/project/model/projectSlice"; +import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; +import { prefetchSplitPanelsLayout } from "#/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout"; +import { useAppSelector } from "#/store/hooks"; + +/** + * True when playground panel content can replace the route/panel skeleton + * without a visible layout jump (split layout chunk loaded + project initialized). + */ +export const usePlaygroundPanelsReady = (): boolean => { + const isMobile = usePlaygroundMobileLayout(); + const isInitialized = useAppSelector(selectIsInitialized); + const route = usePlaygroundRoute(); + const projectSlug = route?.slug[0] ?? ""; + const [splitLayoutReady, setSplitLayoutReady] = useState(false); + + // Desktop: wait for the shared split-layout prefetch started in playground layout. + useEffect(() => { + if (isMobile) { + return; + } + + let cancelled = false; + + void prefetchSplitPanelsLayout().then(() => { + if (!cancelled) { + setSplitLayoutReady(true); + } + }); + + return () => { + cancelled = true; + }; + }, [isMobile]); + + return useMemo(() => { + if (!route) { + return false; + } + + if (isMobile) { + if (!projectSlug) { + return true; + } + + return isInitialized; + } + + if (!splitLayoutReady) { + return false; + } + + if (!projectSlug) { + return true; + } + + return isInitialized; + }, [isInitialized, isMobile, projectSlug, route, splitLayoutReady]); +}; diff --git a/src/features/playground/hooks/usePlaygroundPyodideWarmup.ts b/src/features/playground/hooks/usePlaygroundPyodideWarmup.ts new file mode 100644 index 00000000..3a482d9f --- /dev/null +++ b/src/features/playground/hooks/usePlaygroundPyodideWarmup.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect } from "react"; + +import { usePyodideProgressSnackbar } from "#/features/codeRunner/hooks/usePyodideProgressSnackbar"; +import { pythonRunner } from "#/features/codeRunner/lib/pythonRunner"; +import { pyodideSlice } from "#/features/codeRunner/model/pyodideSlice"; +import { useAppDispatch } from "#/store/hooks"; + +/** Delay after 100% so the progress bar animation completes before snackbar closes. */ +const PROGRESS_COMPLETE_DELAY_MS = 400; + +/** + * Warm Pyodide once per playground segment visit. + * Lives in playground layout so loading → page transitions do not restart init. + */ +export const usePlaygroundPyodideWarmup = (): void => { + const dispatch = useAppDispatch(); + + usePyodideProgressSnackbar(); + + // Preload Pyodide when entering the playground segment; progress drives the snackbar. + useEffect(() => { + if (pythonRunner.isReady) return; + + let cancelled = false; + + dispatch( + pyodideSlice.actions.setProgress({ value: 0, stage: "Starting…" }), + ); + + let completeTimeoutId: ReturnType | null = null; + + pythonRunner + .init({ + onProgress: (value, stage) => { + if (!cancelled) { + dispatch(pyodideSlice.actions.setProgress({ value, stage })); + } + }, + }) + .catch(() => undefined) + .finally(() => { + if (cancelled) return; + + completeTimeoutId = setTimeout(() => { + completeTimeoutId = null; + if (!cancelled) { + dispatch(pyodideSlice.actions.clearProgress()); + } + }, PROGRESS_COMPLETE_DELAY_MS); + }); + + return () => { + cancelled = true; + if (completeTimeoutId !== null) { + clearTimeout(completeTimeoutId); + } + dispatch(pyodideSlice.actions.clearProgress()); + }; + }, [dispatch]); +}; diff --git a/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts b/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts index b6fccdc3..14083a8a 100644 --- a/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts +++ b/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts @@ -1,3 +1,5 @@ +"use client"; + import { useLayoutEffect } from "react"; import { pythonRunner } from "#/features/codeRunner/lib/pythonRunner"; diff --git a/src/features/playground/hooks/usePlaygroundSlugLoadingSync.ts b/src/features/playground/hooks/usePlaygroundSlugLoadingSync.ts new file mode 100644 index 00000000..c5adbc25 --- /dev/null +++ b/src/features/playground/hooks/usePlaygroundSlugLoadingSync.ts @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { usePlaygroundInitialData } from "#/features/playground/context/PlaygroundInitialDataContext"; +import { serverPrefetchMatchesRoute } from "#/features/playground/lib/serverPrefetchMatchesRoute"; +import { projectSlice } from "#/features/project/model/projectSlice"; +import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; +import { useAppDispatch } from "#/store/hooks"; + +/** + * Reset the panel loading gate when playground URL segments change + * (back/forward, , or programmatic navigations — not only setProject). + * Skips the initial loadStart when the server already prefetched matching data. + */ +export const usePlaygroundSlugLoadingSync = (): void => { + const dispatch = useAppDispatch(); + const route = usePlaygroundRoute(); + const serverInitialData = usePlaygroundInitialData(); + const slugKey = route?.slug.join("/") ?? ""; + const previousSlugKeyRef = useRef(null); + const isFirstSlugEffectRef = useRef(true); + + useEffect(() => { + if (!route) { + return; + } + + if (previousSlugKeyRef.current === slugKey) { + return; + } + + const skipInitialLoadStart = + isFirstSlugEffectRef.current && + serverPrefetchMatchesRoute(serverInitialData, route.slug); + + previousSlugKeyRef.current = slugKey; + isFirstSlugEffectRef.current = false; + + if (skipInitialLoadStart) { + return; + } + + dispatch(projectSlice.actions.loadStart()); + }, [dispatch, route, serverInitialData, slugKey]); +}; diff --git a/src/features/playground/lib/__tests__/loadProjectSeoFieldsForSession.test.ts b/src/features/playground/lib/__tests__/loadProjectSeoFieldsForSession.test.ts new file mode 100644 index 00000000..3d740446 --- /dev/null +++ b/src/features/playground/lib/__tests__/loadProjectSeoFieldsForSession.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFindUnique = vi.fn(); +const mockGetServerSession = vi.fn(); + +vi.mock("#/server/db/client", () => ({ + db: { + playgroundProject: { + findUnique: mockFindUnique, + }, + }, +})); + +vi.mock("#/server/auth/authOptions", () => ({ + authOptions: {}, +})); + +vi.mock("next-auth", () => ({ + getServerSession: (...args: unknown[]) => mockGetServerSession(...args), +})); + +describe("loadProjectSeoFieldsForSession", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetServerSession.mockResolvedValue({ + user: { id: "user-1", isAdmin: false }, + }); + }); + + it("returns null when there is no session", async () => { + mockGetServerSession.mockResolvedValue(null); + + const { loadProjectSeoFieldsForSession } = + await import("#/features/playground/lib/loadProjectSeoFieldsForSession"); + const result = await loadProjectSeoFieldsForSession("private-project"); + + expect(result).toBeNull(); + expect(mockFindUnique).not.toHaveBeenCalled(); + }); + + it("returns private project SEO for the owner", async () => { + mockFindUnique.mockResolvedValue({ + title: "Secret Project", + description: "Owner only", + isPublic: false, + userId: "user-1", + }); + + const { loadProjectSeoFieldsForSession } = + await import("#/features/playground/lib/loadProjectSeoFieldsForSession"); + const result = await loadProjectSeoFieldsForSession("secret-project"); + + expect(result).toEqual({ + title: "Secret Project", + description: "Owner only", + }); + }); + + it("returns null for private projects when viewer is not owner", async () => { + mockFindUnique.mockResolvedValue({ + title: "Secret Project", + description: "Owner only", + isPublic: false, + userId: "other-user", + }); + + const { loadProjectSeoFieldsForSession } = + await import("#/features/playground/lib/loadProjectSeoFieldsForSession"); + const result = await loadProjectSeoFieldsForSession("secret-project"); + + expect(result).toBeNull(); + }); +}); diff --git a/src/features/playground/lib/__tests__/playgroundProjectSeo.test.ts b/src/features/playground/lib/__tests__/playgroundProjectSeo.test.ts new file mode 100644 index 00000000..7aff65c8 --- /dev/null +++ b/src/features/playground/lib/__tests__/playgroundProjectSeo.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFindUnique = vi.fn(); + +vi.mock("#/server/db/client", () => ({ + db: { + playgroundProject: { + findUnique: mockFindUnique, + }, + }, +})); + +describe("queryPublicProjectSeoFields", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindUnique.mockResolvedValue({ + title: "Two Sum", + description: "Find two numbers", + }); + }); + + it("queries only public projects by slug", async () => { + const { queryPublicProjectSeoFields } = + await import("#/features/playground/lib/queryPublicProjectSeoFields"); + const result = await queryPublicProjectSeoFields("two-sum"); + + expect(mockFindUnique).toHaveBeenCalledWith({ + where: { slug: "two-sum", isPublic: true }, + select: { title: true, description: true }, + }); + expect(result?.title).toBe("Two Sum"); + }); +}); + +describe("playgroundProjectSeoCacheTag", () => { + it("builds a stable tag per slug", async () => { + const { playgroundProjectSeoCacheTag } = + await import("#/features/playground/lib/playgroundProjectSeoCache"); + expect(playgroundProjectSeoCacheTag("two-sum")).toBe( + "playground-project-seo:two-sum", + ); + }); +}); diff --git a/src/features/playground/lib/__tests__/playgroundProjectSeoCache.test.ts b/src/features/playground/lib/__tests__/playgroundProjectSeoCache.test.ts new file mode 100644 index 00000000..f14e425b --- /dev/null +++ b/src/features/playground/lib/__tests__/playgroundProjectSeoCache.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +const mockRevalidateTag = vi.fn(); + +vi.mock("next/cache", () => ({ + revalidateTag: (...args: unknown[]) => mockRevalidateTag(...args), +})); + +describe("playgroundProjectSeoCache", () => { + it("revalidates public list when a public project changes", async () => { + const { revalidatePublicPlaygroundProject } = + await import("#/features/playground/lib/playgroundProjectSeoCache"); + + revalidatePublicPlaygroundProject("two-sum"); + + expect(mockRevalidateTag).toHaveBeenCalledWith( + "playground-project-seo:two-sum", + "hours", + ); + expect(mockRevalidateTag).toHaveBeenCalledWith( + "playground-public-projects-brief", + "hours", + ); + }); +}); diff --git a/src/features/playground/lib/__tests__/serverPrefetchMatchesRoute.test.ts b/src/features/playground/lib/__tests__/serverPrefetchMatchesRoute.test.ts new file mode 100644 index 00000000..91a2a46c --- /dev/null +++ b/src/features/playground/lib/__tests__/serverPrefetchMatchesRoute.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { serverPrefetchMatchesRoute } from "#/features/playground/lib/serverPrefetchMatchesRoute"; + +describe("serverPrefetchMatchesRoute", () => { + it("returns false when project prefetch is missing", () => { + expect(serverPrefetchMatchesRoute(null, ["two-sum"])).toBe(false); + }); + + it("returns true when all prefetched slugs match the URL", () => { + expect( + serverPrefetchMatchesRoute( + { + projectBySlug: { slug: "two-sum" }, + caseBySlug: { slug: "case-1" }, + solutionBySlug: { slug: "solution-1" }, + }, + ["two-sum", "case-1", "solution-1"], + ), + ).toBe(true); + }); + + it("returns false when a slug segment differs", () => { + expect( + serverPrefetchMatchesRoute( + { + projectBySlug: { slug: "two-sum" }, + caseBySlug: { slug: "case-1" }, + solutionBySlug: null, + }, + ["two-sum", "case-2"], + ), + ).toBe(false); + }); +}); diff --git a/src/features/playground/lib/loadProjectSeoFieldsForSession.ts b/src/features/playground/lib/loadProjectSeoFieldsForSession.ts new file mode 100644 index 00000000..971bab40 --- /dev/null +++ b/src/features/playground/lib/loadProjectSeoFieldsForSession.ts @@ -0,0 +1,45 @@ +import { getServerSession } from "next-auth"; + +import type { PublicProjectSeoFields } from "#/features/playground/lib/loadPublicProjectSeoFields"; +import { authOptions } from "#/server/auth/authOptions"; +import { db } from "#/server/db/client"; + +/** + * Uncached SEO lookup for private projects when the viewer is owner or admin. + * Returns null for anonymous users and non-owners (no title leakage in ``). + */ +export async function loadProjectSeoFieldsForSession( + slug: string, +): Promise { + const session = await getServerSession(authOptions); + const userId = session?.user?.id; + if (!userId) { + return null; + } + + const project = await db.playgroundProject.findUnique({ + where: { slug }, + select: { + title: true, + description: true, + isPublic: true, + userId: true, + }, + }); + + if (!project) { + return null; + } + + if (project.isPublic) { + return { title: project.title, description: project.description }; + } + + const isOwner = project.userId === userId; + const isAdmin = Boolean(session.user.isAdmin); + if (!isOwner && !isAdmin) { + return null; + } + + return { title: project.title, description: project.description }; +} diff --git a/src/features/playground/lib/loadPublicProjectSeoFields.ts b/src/features/playground/lib/loadPublicProjectSeoFields.ts new file mode 100644 index 00000000..861b4bfa --- /dev/null +++ b/src/features/playground/lib/loadPublicProjectSeoFields.ts @@ -0,0 +1,23 @@ +import { cacheLife, cacheTag } from "next/cache"; + +import { playgroundProjectSeoCacheTag } from "#/features/playground/lib/playgroundProjectSeoCache"; +import { + type PublicProjectSeoFields, + queryPublicProjectSeoFields, +} from "#/features/playground/lib/queryPublicProjectSeoFields"; + +export type { PublicProjectSeoFields }; + +/** + * Cached public project fields for playground `` / meta description. + * Invalidated via {@link revalidatePlaygroundProjectSeo} on project mutations. + */ +export async function loadPublicProjectSeoFields( + slug: string, +): Promise<PublicProjectSeoFields | null> { + "use cache"; + cacheLife("hours"); + cacheTag(playgroundProjectSeoCacheTag(slug)); + + return queryPublicProjectSeoFields(slug); +} diff --git a/src/features/playground/lib/playgroundCacheTags.ts b/src/features/playground/lib/playgroundCacheTags.ts new file mode 100644 index 00000000..cc14b44b --- /dev/null +++ b/src/features/playground/lib/playgroundCacheTags.ts @@ -0,0 +1,5 @@ +export const PUBLIC_PROJECTS_BRIEF_CACHE_TAG = + "playground-public-projects-brief"; + +export const playgroundProjectSeoCacheTag = (slug: string) => + `playground-project-seo:${slug}`; diff --git a/src/features/playground/lib/playgroundProjectSeoCache.ts b/src/features/playground/lib/playgroundProjectSeoCache.ts new file mode 100644 index 00000000..d4cff2e5 --- /dev/null +++ b/src/features/playground/lib/playgroundProjectSeoCache.ts @@ -0,0 +1,24 @@ +import { revalidateTag } from "next/cache"; + +import { + playgroundProjectSeoCacheTag, + PUBLIC_PROJECTS_BRIEF_CACHE_TAG, +} from "#/features/playground/lib/playgroundCacheTags"; + +export { playgroundProjectSeoCacheTag } from "#/features/playground/lib/playgroundCacheTags"; + +/** Bust cached public SEO fields after project create/update/delete. */ +export function revalidatePlaygroundProjectSeo(slug: string): void { + revalidateTag(playgroundProjectSeoCacheTag(slug), "hours"); +} + +/** Bust cached public project list used for server canonical redirects. */ +export function revalidatePublicProjectsBrief(): void { + revalidateTag(PUBLIC_PROJECTS_BRIEF_CACHE_TAG, "hours"); +} + +/** Revalidate SEO + public list when a public playground project changes. */ +export function revalidatePublicPlaygroundProject(slug: string): void { + revalidatePlaygroundProjectSeo(slug); + revalidatePublicProjectsBrief(); +} diff --git a/src/features/playground/lib/queryPublicProjectSeoFields.ts b/src/features/playground/lib/queryPublicProjectSeoFields.ts new file mode 100644 index 00000000..9e3c7453 --- /dev/null +++ b/src/features/playground/lib/queryPublicProjectSeoFields.ts @@ -0,0 +1,16 @@ +import { db } from "#/server/db/client"; + +export type PublicProjectSeoFields = { + title: string; + description: string | null; +}; + +/** Uncached DB read for public project SEO fields (wrapped by `loadPublicProjectSeoFields`). */ +export async function queryPublicProjectSeoFields( + slug: string, +): Promise<PublicProjectSeoFields | null> { + return db.playgroundProject.findUnique({ + where: { slug, isPublic: true }, + select: { title: true, description: true }, + }); +} diff --git a/src/features/playground/lib/resolvePlaygroundPageSeo.ts b/src/features/playground/lib/resolvePlaygroundPageSeo.ts index 34ed4da6..539f8923 100644 --- a/src/features/playground/lib/resolvePlaygroundPageSeo.ts +++ b/src/features/playground/lib/resolvePlaygroundPageSeo.ts @@ -1,7 +1,8 @@ +import { loadProjectSeoFieldsForSession } from "#/features/playground/lib/loadProjectSeoFieldsForSession"; +import { loadPublicProjectSeoFields } from "#/features/playground/lib/loadPublicProjectSeoFields"; import { createTranslationFunctions } from "#/i18n/createTranslationFunctions"; import type { Locales } from "#/i18n/i18n-types"; -import { importLocaleAsync } from "#/i18n/i18n-util.async"; -import { db } from "#/server/db/client"; +import { loadI18nForLocale } from "#/i18n/loadI18nForLocale"; export type PlaygroundPageSeo = { pageTitle: string; @@ -13,17 +14,20 @@ export async function resolvePlaygroundPageSeo( locale: Locales, slugStr?: string, ): Promise<PlaygroundPageSeo> { - const translation = await importLocaleAsync(locale); + const { translations } = await loadI18nForLocale(locale); + const translation = translations[locale]; + if (!translation) { + throw new Error(`Missing translations for locale: ${locale}`); + } const LL = createTranslationFunctions(locale, translation); let pageTitle: string = LL.PLAYGROUND_SEO_TITLE(); let pageDescription: string = LL.SITE_SEO_DESCRIPTION(); if (slugStr) { - const project = await db.playgroundProject.findUnique({ - where: { slug: slugStr }, - select: { title: true, description: true }, - }); + const project = + (await loadPublicProjectSeoFields(slugStr)) ?? + (await loadProjectSeoFieldsForSession(slugStr)); if (project) { pageTitle = `${project.title} | dStruct`; pageDescription = project.description?.trim() diff --git a/src/features/playground/lib/serverPrefetchMatchesRoute.ts b/src/features/playground/lib/serverPrefetchMatchesRoute.ts new file mode 100644 index 00000000..e1e7273e --- /dev/null +++ b/src/features/playground/lib/serverPrefetchMatchesRoute.ts @@ -0,0 +1,31 @@ +type ServerPrefetchSlugData = { + projectBySlug?: { slug: string } | null; + caseBySlug?: { slug: string } | null; + solutionBySlug?: { slug: string } | null; +}; + +/** True when server-prefetched playground data matches the active URL slug segments. */ +export function serverPrefetchMatchesRoute( + serverInitialData: ServerPrefetchSlugData | null, + slug: string[], +): boolean { + if (!serverInitialData?.projectBySlug) { + return false; + } + + const [projectSlug, caseSlug, solutionSlug] = slug; + + if (serverInitialData.projectBySlug.slug !== projectSlug) { + return false; + } + + if (caseSlug && serverInitialData.caseBySlug?.slug !== caseSlug) { + return false; + } + + if (solutionSlug && serverInitialData.solutionBySlug?.slug !== solutionSlug) { + return false; + } + + return true; +} diff --git a/src/features/playground/ui/PlaygroundLayoutClient.tsx b/src/features/playground/ui/PlaygroundLayoutClient.tsx new file mode 100644 index 00000000..6b1307a8 --- /dev/null +++ b/src/features/playground/ui/PlaygroundLayoutClient.tsx @@ -0,0 +1,51 @@ +"use client"; + +import React, { type ReactNode, useEffect } from "react"; + +import { useBarePlaygroundBrowseLanding } from "#/features/playground/hooks/useBarePlaygroundBrowseLanding"; +import { useClientCanonicalPlaygroundRedirect } from "#/features/playground/hooks/useClientCanonicalPlaygroundRedirect"; +import { usePlaygroundPyodideWarmup } from "#/features/playground/hooks/usePlaygroundPyodideWarmup"; +import { usePlaygroundRuntimeRelease } from "#/features/playground/hooks/usePlaygroundRuntimeRelease"; +import { usePlaygroundSlugLoadingSync } from "#/features/playground/hooks/usePlaygroundSlugLoadingSync"; +import { PlaygroundPageShell } from "#/features/playground/ui/PlaygroundPageShell"; +import { ProjectBrowserProvider } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; +import { prefetchSplitPanelsLayout } from "#/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout"; + +import { ApolloHydrationProvider } from "#/app/locale-app/ApolloHydrationProvider"; +import { ProjectBrowserOverlay } from "#/app/locale-app/ProjectBrowserOverlay"; + +type PlaygroundLayoutClientProps = { + children: ReactNode; +}; + +const PlaygroundRouteEffects: React.FC = () => { + usePlaygroundRuntimeRelease(); + usePlaygroundPyodideWarmup(); + useClientCanonicalPlaygroundRedirect(); + usePlaygroundSlugLoadingSync(); + useBarePlaygroundBrowseLanding(); + + useEffect(() => { + void prefetchSplitPanelsLayout(); + }, []); + + return null; +}; + +/** + * Persistent playground segment chrome — survives loading.tsx → page swaps + * so instant navigations do not remount the header or restart Pyodide. + */ +export const PlaygroundLayoutClient: React.FC<PlaygroundLayoutClientProps> = ({ + children, +}) => { + return ( + <ApolloHydrationProvider initialCache={null}> + <ProjectBrowserProvider> + <PlaygroundRouteEffects /> + <PlaygroundPageShell>{children}</PlaygroundPageShell> + <ProjectBrowserOverlay /> + </ProjectBrowserProvider> + </ApolloHydrationProvider> + ); +}; diff --git a/src/features/playground/ui/PlaygroundPageLoading.tsx b/src/features/playground/ui/PlaygroundPageLoading.tsx new file mode 100644 index 00000000..91dd3daa --- /dev/null +++ b/src/features/playground/ui/PlaygroundPageLoading.tsx @@ -0,0 +1,10 @@ +"use client"; + +import React from "react"; + +import { PlaygroundPanelsSkeleton } from "#/features/playground/ui/PlaygroundPanelsSkeleton"; + +/** Route-level instant-nav fallback — panel area only; shell lives in playground layout. */ +export const PlaygroundPageLoading: React.FC = () => ( + <PlaygroundPanelsSkeleton /> +); diff --git a/src/features/playground/ui/PlaygroundPageShell.tsx b/src/features/playground/ui/PlaygroundPageShell.tsx new file mode 100644 index 00000000..566401eb --- /dev/null +++ b/src/features/playground/ui/PlaygroundPageShell.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { darken, useTheme } from "@mui/material"; +import React, { type ReactNode } from "react"; + +import { MainAppBar } from "#/features/appBar/ui/MainAppBar"; +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; +import { PageScrollContainer } from "#/shared/ui/templates/PageScrollContainer"; + +type PlaygroundPageShellProps = { + children: ReactNode; +}; + +/** + * Shared chrome for playground routes: scroll container, background, and app bar. + * Rendered from playground `layout.tsx` so it persists across loading → page swaps. + */ +export const PlaygroundPageShell: React.FC<PlaygroundPageShellProps> = ({ + children, +}) => { + const theme = useTheme(); + const isMobile = usePlaygroundMobileLayout(); + + return ( + <PageScrollContainer + isPage={true} + options={ + isMobile + ? { overflow: { x: "hidden", y: "hidden" } } + : { scrollbars: { autoHide: "scroll" }, overflow: { x: "hidden" } } + } + style={{ + height: "100vh", + background: darken(theme.palette.background.default, 0.1), + }} + > + <MainAppBar toolbarVariant="dense" /> + {children} + </PageScrollContainer> + ); +}; diff --git a/src/features/playground/ui/PlaygroundPageView.tsx b/src/features/playground/ui/PlaygroundPageView.tsx index 9d36f553..7ea9b9f5 100644 --- a/src/features/playground/ui/PlaygroundPageView.tsx +++ b/src/features/playground/ui/PlaygroundPageView.tsx @@ -1,89 +1,42 @@ "use client"; -import { darken, useTheme } from "@mui/material"; import React from "react"; import { ConfigContext } from "#/context"; -import { MainAppBar } from "#/features/appBar/ui/MainAppBar"; import { CodePanel } from "#/features/codeRunner/ui/CodePanel"; import { OutputPanel } from "#/features/output/ui/OutputPanel"; import { PlaygroundViewProvider } from "#/features/playground/context/PlaygroundViewContext"; -import { usePlaygroundRuntimeRelease } from "#/features/playground/hooks/usePlaygroundRuntimeRelease"; +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; import { MobilePlayground } from "#/features/playground/ui/MobilePlayground"; +import { PlaygroundPanelsGate } from "#/features/playground/ui/PlaygroundPanelsGate"; import { ProjectPanel } from "#/features/project/ui/ProjectPanel"; import { TreeViewPanel } from "#/features/treeViewer/ui/TreeViewPanel"; -import { useAppConfig, useHasMounted } from "#/shared/hooks"; -import { useMobileLayout } from "#/shared/hooks/useMobileLayout"; -import { PageScrollContainer } from "#/shared/ui/templates/PageScrollContainer"; -import type { SplitPanelsLayoutProps } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"; -import { SplitPanelsLayout } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"; -import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; +import { useAppConfig } from "#/shared/hooks"; +import { SplitPanelsLayoutClient } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient"; -type DesktopWrapperProps = SplitPanelsLayoutProps; - -const DesktopWrapper: React.FC<DesktopWrapperProps> = ({ - TopLeft, - BottomLeft, - TopRight, - BottomRight, -}) => { - const hasMounted = useHasMounted(); - - // Defer split layout until after mount to avoid Emotion hydration mismatch - // (server and client can render the four panels in different order). - if (!hasMounted) return <SplitPanelsLayoutSkeleton />; - - return ( - <SplitPanelsLayout - component="main" - TopLeft={TopLeft} - BottomLeft={BottomLeft} - TopRight={TopRight} - BottomRight={BottomRight} - /> - ); -}; - -/** Playground shell shared by Pages `/playground` and App pilot routes. */ +/** Playground shell — desktop split layout or mobile phased UI inside shared chrome. */ export const PlaygroundPageView: React.FC = () => { - const theme = useTheme(); - const isMobile = useMobileLayout(); - - usePlaygroundRuntimeRelease(); + const isMobile = usePlaygroundMobileLayout(); const { data = {} } = useAppConfig(); return ( <ConfigContext.Provider value={data}> - <PageScrollContainer - isPage={true} - options={ - isMobile - ? { overflow: { x: "hidden", y: "hidden" } } - : { scrollbars: { autoHide: "scroll" }, overflow: { x: "hidden" } } - } - style={{ - height: "100vh", - background: darken(theme.palette.background.default, 0.1), - }} - > + <PlaygroundPanelsGate> {isMobile ? ( <PlaygroundViewProvider> - <MainAppBar toolbarVariant="dense" /> <MobilePlayground /> </PlaygroundViewProvider> ) : ( - <> - <MainAppBar toolbarVariant="dense" /> - <DesktopWrapper - TopLeft={ProjectPanel} - BottomLeft={CodePanel} - TopRight={TreeViewPanel} - BottomRight={OutputPanel} - /> - </> + <SplitPanelsLayoutClient + component="main" + TopLeft={ProjectPanel} + BottomLeft={CodePanel} + TopRight={TreeViewPanel} + BottomRight={OutputPanel} + /> )} - </PageScrollContainer> + </PlaygroundPanelsGate> </ConfigContext.Provider> ); }; diff --git a/src/features/playground/ui/PlaygroundPanelsGate.tsx b/src/features/playground/ui/PlaygroundPanelsGate.tsx new file mode 100644 index 00000000..52bdb3d0 --- /dev/null +++ b/src/features/playground/ui/PlaygroundPanelsGate.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Box } from "@mui/material"; +import React, { type ReactNode } from "react"; + +import { + MOBILE_APPBAR_HEIGHT, + PLAYGROUND_DESKTOP_APP_BAR_HEIGHT, +} from "#/features/appBar/constants"; +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; +import { usePlaygroundPanelsReady } from "#/features/playground/hooks/usePlaygroundPanelsReady"; +import { PlaygroundPanelsSkeleton } from "#/features/playground/ui/PlaygroundPanelsSkeleton"; + +type PlaygroundPanelsGateProps = { + children: ReactNode; +}; + +/** + * Keeps one continuous panel skeleton from route loading through client hydration. + * Panels mount underneath (opacity 0) so editors can finish init before reveal. + */ +export const PlaygroundPanelsGate: React.FC<PlaygroundPanelsGateProps> = ({ + children, +}) => { + const isMobile = usePlaygroundMobileLayout(); + const panelsReady = usePlaygroundPanelsReady(); + + const panelAreaMinHeight = isMobile + ? `calc(100vh - ${MOBILE_APPBAR_HEIGHT}px - env(safe-area-inset-top, 0px))` + : `calc(100vh - ${PLAYGROUND_DESKTOP_APP_BAR_HEIGHT}px)`; + + return ( + <Box sx={{ position: "relative" }}> + {!panelsReady ? ( + <Box sx={{ position: "absolute", inset: 0, zIndex: 2 }}> + <PlaygroundPanelsSkeleton /> + </Box> + ) : null} + <Box + aria-busy={!panelsReady} + aria-hidden={!panelsReady} + sx={{ + minHeight: panelAreaMinHeight, + opacity: panelsReady ? 1 : 0, + pointerEvents: panelsReady ? "auto" : "none", + }} + > + {children} + </Box> + </Box> + ); +}; diff --git a/src/features/playground/ui/PlaygroundPanelsSkeleton.tsx b/src/features/playground/ui/PlaygroundPanelsSkeleton.tsx new file mode 100644 index 00000000..4c868355 --- /dev/null +++ b/src/features/playground/ui/PlaygroundPanelsSkeleton.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Box, Skeleton } from "@mui/material"; +import React from "react"; + +import { MOBILE_APPBAR_HEIGHT } from "#/features/appBar/constants"; +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; +import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; + +const MobilePlaygroundPanelsSkeleton: React.FC = () => ( + <Box + component="main" + sx={{ + height: `calc(100vh - ${MOBILE_APPBAR_HEIGHT}px - env(safe-area-inset-top, 0px))`, + px: 1, + pb: 1, + overflow: "hidden", + }} + > + <Skeleton + variant="rounded" + animation="wave" + sx={{ height: "100%", borderRadius: 2, cursor: "wait" }} + /> + </Box> +); + +/** Playground panel-area skeleton (desktop split layout or mobile full-bleed). */ +export const PlaygroundPanelsSkeleton: React.FC = () => { + const isMobile = usePlaygroundMobileLayout(); + + if (isMobile) { + return <MobilePlaygroundPanelsSkeleton />; + } + + return <SplitPanelsLayoutSkeleton />; +}; diff --git a/src/features/privacy/ui/PrivacyPageContent.tsx b/src/features/privacy/ui/PrivacyPageContent.tsx new file mode 100644 index 00000000..878f3a57 --- /dev/null +++ b/src/features/privacy/ui/PrivacyPageContent.tsx @@ -0,0 +1,122 @@ +import { Box, Container, Link as MuiLink, Typography } from "@mui/material"; +import Link from "next/link"; +import React from "react"; + +import { PrivacyCookieInventoryTable } from "#/features/privacy/ui/PrivacyCookieInventoryTable"; +import type { TranslationFunctions } from "#/i18n/i18n-types"; + +const PrivacySection: React.FC<{ + title: string; + children: React.ReactNode; +}> = ({ title, children }) => ( + <Box component="section" sx={{ mt: 4 }}> + <Typography variant="h5" component="h2" gutterBottom> + {title} + </Typography> + {children} + </Box> +); + +const PrivacyParagraph: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( + <Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}> + {children} + </Typography> +); + +type PrivacyPageContentProps = { + LL: TranslationFunctions; + homePath: string; +}; + +/** Server-rendered privacy policy body (marketing layout supplies client chrome). */ +export const PrivacyPageContent: React.FC<PrivacyPageContentProps> = ({ + LL, + homePath, +}) => ( + <Container maxWidth="md" sx={{ py: 4 }}> + <Typography variant="h4" component="h1" gutterBottom> + {LL.PRIVACY_PAGE_TITLE()} + </Typography> + <PrivacyParagraph>{LL.PRIVACY_INTRO()}</PrivacyParagraph> + <PrivacyParagraph>{LL.PRIVACY_LAST_UPDATED()}</PrivacyParagraph> + + <PrivacySection title={LL.PRIVACY_CONTROLLER_TITLE()}> + <PrivacyParagraph>{LL.PRIVACY_CONTROLLER_BODY()}</PrivacyParagraph> + <PrivacyParagraph> + {LL.PRIVACY_CONTACT_INTRO()}{" "} + <MuiLink href={`mailto:${LL.PRIVACY_CONTACT_EMAIL()}`}> + {LL.PRIVACY_CONTACT_EMAIL()} + </MuiLink> + . + </PrivacyParagraph> + </PrivacySection> + + <PrivacySection title={LL.PRIVACY_DATA_COLLECTED_TITLE()}> + <PrivacyParagraph>{LL.PRIVACY_DATA_COLLECTED_BODY()}</PrivacyParagraph> + </PrivacySection> + + <PrivacySection title={LL.PRIVACY_LEGAL_BASES_TITLE()}> + <PrivacyParagraph>{LL.PRIVACY_LEGAL_BASES_BODY()}</PrivacyParagraph> + </PrivacySection> + + <PrivacySection title={LL.PRIVACY_RETENTION_TITLE()}> + <PrivacyParagraph>{LL.PRIVACY_RETENTION_BODY()}</PrivacyParagraph> + </PrivacySection> + + <PrivacySection title={LL.PRIVACY_RIGHTS_TITLE()}> + <PrivacyParagraph>{LL.PRIVACY_RIGHTS_BODY()}</PrivacyParagraph> + <PrivacyParagraph>{LL.PRIVACY_WITHDRAW_CONSENT_BODY()}</PrivacyParagraph> + </PrivacySection> + + <PrivacySection title={LL.PRIVACY_SUBPROCESSORS_TITLE()}> + <PrivacyParagraph>{LL.PRIVACY_SUBPROCESSORS_BODY()}</PrivacyParagraph> + </PrivacySection> + + <PrivacySection title={LL.PRIVACY_TRANSFERS_TITLE()}> + <PrivacyParagraph>{LL.PRIVACY_TRANSFERS_BODY()}</PrivacyParagraph> + </PrivacySection> + + <PrivacySection title={LL.PRIVACY_COOKIES_SECTION_TITLE()}> + <PrivacyParagraph>{LL.PRIVACY_COOKIES_OVERVIEW_BODY()}</PrivacyParagraph> + + <Typography variant="h6" component="h3" sx={{ mt: 3, mb: 1 }}> + {LL.PRIVACY_COOKIES_ESSENTIAL_TITLE()} + </Typography> + <PrivacyParagraph>{LL.PRIVACY_COOKIES_ESSENTIAL_BODY()}</PrivacyParagraph> + + <Typography variant="h6" component="h3" sx={{ mt: 3, mb: 1 }}> + {LL.PRIVACY_COOKIES_PREFERENCES_TITLE()} + </Typography> + <PrivacyParagraph> + {LL.PRIVACY_COOKIES_PREFERENCES_BODY()} + </PrivacyParagraph> + + <Typography variant="h6" component="h3" sx={{ mt: 3, mb: 1 }}> + {LL.PRIVACY_COOKIES_ANALYTICS_TITLE()} + </Typography> + <PrivacyParagraph>{LL.PRIVACY_COOKIES_ANALYTICS_BODY()}</PrivacyParagraph> + + <Typography variant="h6" component="h3" sx={{ mt: 3, mb: 1 }}> + {LL.PRIVACY_COOKIE_TABLE_TITLE()} + </Typography> + <PrivacyParagraph>{LL.PRIVACY_COOKIE_TABLE_INTRO()}</PrivacyParagraph> + <PrivacyCookieInventoryTable LL={LL} /> + </PrivacySection> + + <PrivacySection title={LL.PRIVACY_EXECUTION_TITLE()}> + <PrivacyParagraph>{LL.PRIVACY_EXECUTION_BODY()}</PrivacyParagraph> + </PrivacySection> + + <PrivacySection title={LL.PRIVACY_CCPA_TITLE()}> + <PrivacyParagraph>{LL.PRIVACY_CCPA_BODY()}</PrivacyParagraph> + </PrivacySection> + + <Typography variant="body2" color="text.secondary" sx={{ mt: 4 }}> + <Link href={homePath} style={{ color: "inherit" }}> + {LL.DASHBOARD()} + </Link> + </Typography> + </Container> +); diff --git a/src/features/privacy/ui/PrivacyPageView.tsx b/src/features/privacy/ui/PrivacyPageView.tsx deleted file mode 100644 index 993d5137..00000000 --- a/src/features/privacy/ui/PrivacyPageView.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import { Box, Container, Link as MuiLink, Typography } from "@mui/material"; -import Link from "next/link"; -import React from "react"; - -import { PrivacyCookieInventoryTable } from "#/features/privacy/ui/PrivacyCookieInventoryTable"; -import { useI18nContext } from "#/shared/hooks"; -import { MainLayout } from "#/shared/ui/templates/MainLayout"; - -const PrivacySection: React.FC<{ - title: string; - children: React.ReactNode; -}> = ({ title, children }) => ( - <Box component="section" sx={{ mt: 4 }}> - <Typography variant="h5" component="h2" gutterBottom> - {title} - </Typography> - {children} - </Box> -); - -const PrivacyParagraph: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => ( - <Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}> - {children} - </Typography> -); - -/** Privacy policy content. Shared by Pages `/privacy` and App Router pilot. */ -export const PrivacyPageView: React.FC = () => { - const { LL } = useI18nContext(); - - return ( - <MainLayout> - <Container maxWidth="md" sx={{ py: 4 }}> - <Typography variant="h4" component="h1" gutterBottom> - {LL.PRIVACY_PAGE_TITLE()} - </Typography> - <PrivacyParagraph>{LL.PRIVACY_INTRO()}</PrivacyParagraph> - <PrivacyParagraph>{LL.PRIVACY_LAST_UPDATED()}</PrivacyParagraph> - - <PrivacySection title={LL.PRIVACY_CONTROLLER_TITLE()}> - <PrivacyParagraph>{LL.PRIVACY_CONTROLLER_BODY()}</PrivacyParagraph> - <PrivacyParagraph> - {LL.PRIVACY_CONTACT_INTRO()}{" "} - <MuiLink href={`mailto:${LL.PRIVACY_CONTACT_EMAIL()}`}> - {LL.PRIVACY_CONTACT_EMAIL()} - </MuiLink> - . - </PrivacyParagraph> - </PrivacySection> - - <PrivacySection title={LL.PRIVACY_DATA_COLLECTED_TITLE()}> - <PrivacyParagraph> - {LL.PRIVACY_DATA_COLLECTED_BODY()} - </PrivacyParagraph> - </PrivacySection> - - <PrivacySection title={LL.PRIVACY_LEGAL_BASES_TITLE()}> - <PrivacyParagraph>{LL.PRIVACY_LEGAL_BASES_BODY()}</PrivacyParagraph> - </PrivacySection> - - <PrivacySection title={LL.PRIVACY_RETENTION_TITLE()}> - <PrivacyParagraph>{LL.PRIVACY_RETENTION_BODY()}</PrivacyParagraph> - </PrivacySection> - - <PrivacySection title={LL.PRIVACY_RIGHTS_TITLE()}> - <PrivacyParagraph>{LL.PRIVACY_RIGHTS_BODY()}</PrivacyParagraph> - <PrivacyParagraph> - {LL.PRIVACY_WITHDRAW_CONSENT_BODY()} - </PrivacyParagraph> - </PrivacySection> - - <PrivacySection title={LL.PRIVACY_SUBPROCESSORS_TITLE()}> - <PrivacyParagraph>{LL.PRIVACY_SUBPROCESSORS_BODY()}</PrivacyParagraph> - </PrivacySection> - - <PrivacySection title={LL.PRIVACY_TRANSFERS_TITLE()}> - <PrivacyParagraph>{LL.PRIVACY_TRANSFERS_BODY()}</PrivacyParagraph> - </PrivacySection> - - <PrivacySection title={LL.PRIVACY_COOKIES_SECTION_TITLE()}> - <PrivacyParagraph> - {LL.PRIVACY_COOKIES_OVERVIEW_BODY()} - </PrivacyParagraph> - - <Typography variant="h6" component="h3" sx={{ mt: 3, mb: 1 }}> - {LL.PRIVACY_COOKIES_ESSENTIAL_TITLE()} - </Typography> - <PrivacyParagraph> - {LL.PRIVACY_COOKIES_ESSENTIAL_BODY()} - </PrivacyParagraph> - - <Typography variant="h6" component="h3" sx={{ mt: 3, mb: 1 }}> - {LL.PRIVACY_COOKIES_PREFERENCES_TITLE()} - </Typography> - <PrivacyParagraph> - {LL.PRIVACY_COOKIES_PREFERENCES_BODY()} - </PrivacyParagraph> - - <Typography variant="h6" component="h3" sx={{ mt: 3, mb: 1 }}> - {LL.PRIVACY_COOKIES_ANALYTICS_TITLE()} - </Typography> - <PrivacyParagraph> - {LL.PRIVACY_COOKIES_ANALYTICS_BODY()} - </PrivacyParagraph> - - <Typography variant="h6" component="h3" sx={{ mt: 3, mb: 1 }}> - {LL.PRIVACY_COOKIE_TABLE_TITLE()} - </Typography> - <PrivacyParagraph>{LL.PRIVACY_COOKIE_TABLE_INTRO()}</PrivacyParagraph> - <PrivacyCookieInventoryTable LL={LL} /> - </PrivacySection> - - <PrivacySection title={LL.PRIVACY_EXECUTION_TITLE()}> - <PrivacyParagraph>{LL.PRIVACY_EXECUTION_BODY()}</PrivacyParagraph> - </PrivacySection> - - <PrivacySection title={LL.PRIVACY_CCPA_TITLE()}> - <PrivacyParagraph>{LL.PRIVACY_CCPA_BODY()}</PrivacyParagraph> - </PrivacySection> - - <Typography variant="body2" color="text.secondary" sx={{ mt: 4 }}> - <MuiLink component={Link} href="/" underline="hover"> - {LL.DASHBOARD()} - </MuiLink> - </Typography> - </Container> - </MainLayout> - ); -}; diff --git a/src/features/profile/ui/ProfilePageGate.tsx b/src/features/profile/ui/ProfilePageGate.tsx deleted file mode 100644 index 1a440a29..00000000 --- a/src/features/profile/ui/ProfilePageGate.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { notFound } from "next/navigation"; - -import { ProfilePageView } from "#/features/profile/ui/ProfilePageView"; - -type ProfilePageGateProps = { - params: Promise<{ userId: string }>; -}; - -/** Server gate: validate `userId` before rendering client profile view (inside Suspense). */ -export async function ProfilePageGate({ params }: ProfilePageGateProps) { - const { userId } = await params; - if (!userId.trim()) { - notFound(); - } - - return <ProfilePageView />; -} diff --git a/src/features/project/hooks/useProjectPanelData.ts b/src/features/project/hooks/useProjectPanelData.ts index 12e7ddee..0fcad28a 100644 --- a/src/features/project/hooks/useProjectPanelData.ts +++ b/src/features/project/hooks/useProjectPanelData.ts @@ -4,6 +4,7 @@ import { TRPCClientError } from "@trpc/client"; import { useSession } from "next-auth/react"; import { useEffect } from "react"; +import { usePlaygroundInitialData } from "#/features/playground/context/PlaygroundInitialDataContext"; import { projectSlice, selectIsEditable, @@ -20,22 +21,20 @@ import { useAppDispatch, useAppSelector } from "#/store/hooks"; export const useProjectPanelData = () => { const session = useSession(); const dispatch = useAppDispatch(); - const playgroundRoute = usePlaygroundRoute(); - const isRouteReady = playgroundRoute !== null; + const { projectSlug = "", caseSlug = "", clearSlugs } = usePlaygroundSlugs(); + const route = usePlaygroundRoute(); - const { - projectSlug = "", - caseSlug = "", - setProject, - clearSlugs, - } = usePlaygroundSlugs(); + const serverInitialData = usePlaygroundInitialData(); - const allBrief = api.project.allBrief.useQuery(); const isEditable = useAppSelector(selectIsEditable); const selectedProject = api.project.getBySlug.useQuery(projectSlug, { enabled: Boolean(projectSlug), + initialData: + serverInitialData?.projectBySlug?.slug === projectSlug + ? serverInitialData.projectBySlug + : undefined, retry(failureCount, error) { if (error instanceof TRPCClientError && error.data.code === "NOT_FOUND") { return false; @@ -58,13 +57,24 @@ export const useProjectPanelData = () => { const selectedCase = api.project.getCaseBySlug.useQuery( { projectId: selectedProject.data?.id || "", slug: caseSlug }, - { enabled: Boolean(selectedProject.data?.id && caseSlug) }, + { + enabled: Boolean(selectedProject.data?.id && caseSlug), + initialData: + serverInitialData?.caseBySlug?.slug === caseSlug && + serverInitialData.projectBySlug?.id === selectedProject.data?.id + ? serverInitialData.caseBySlug + : undefined, + }, ); useEffect(() => { if (selectedProject.error) { console.error("selectedProject.error: ", selectedProject.error); - clearSlugs(); + if (route) { + route.navigateTo(route.basePath, { omitView: true }); + } else { + clearSlugs(); + } return; } if (!selectedProject.data || !session.data) { @@ -83,20 +93,33 @@ export const useProjectPanelData = () => { clearSlugs, dispatch, isEditable, + route, selectedProject.data, selectedProject.error, session.data, ]); - // On landing with no slug, open the first public project once route + brief list are ready. + // Unblock the loading gate when case/solution selection cannot proceed. useEffect(() => { - if (allBrief.data?.length && isRouteReady && !projectSlug) { - const firstProject = allBrief.data[0]; - if (firstProject) { - setProject(firstProject.slug, true); - } + if (!selectedProject.data || selectedProject.isLoading) { + return; + } + + const { cases, solutions } = selectedProject.data; + + if (cases.length === 0) { + dispatch(projectSlice.actions.loadFinish()); + return; + } + + if (!caseSlug) { + return; + } + + if (solutions.length === 0) { + dispatch(projectSlice.actions.loadFinish()); } - }, [allBrief.data, isRouteReady, projectSlug, setProject]); + }, [caseSlug, dispatch, selectedProject.data, selectedProject.isLoading]); return { session, diff --git a/src/features/project/ui/ProjectBrowser/ProjectBrowserContext.tsx b/src/features/project/ui/ProjectBrowser/ProjectBrowserContext.tsx index e9df4d18..216c3aa0 100644 --- a/src/features/project/ui/ProjectBrowser/ProjectBrowserContext.tsx +++ b/src/features/project/ui/ProjectBrowser/ProjectBrowserContext.tsx @@ -266,7 +266,7 @@ const ProjectBrowserContext = createContext<ProjectBrowserContextValue | null>( * const { isOpen, openBrowser, searchQuery, setSearchQuery } = useProjectBrowserContext(); */ export const useProjectBrowserContext = (): ProjectBrowserContextValue => { - const context = useContext(ProjectBrowserContext); + const context = useOptionalProjectBrowserContext(); if (!context) { throw new Error( "useProjectBrowserContext must be used within ProjectBrowserProvider", @@ -276,6 +276,10 @@ export const useProjectBrowserContext = (): ProjectBrowserContextValue => { return context; }; +/** Returns null outside playground {@link ProjectBrowserProvider}. */ +export const useOptionalProjectBrowserContext = + (): ProjectBrowserContextValue | null => useContext(ProjectBrowserContext); + type ProjectBrowserProviderProps = { children: ReactNode; }; diff --git a/src/features/project/ui/TestCaseSelectBar.tsx b/src/features/project/ui/TestCaseSelectBar.tsx index 10ef74d6..f253dce1 100644 --- a/src/features/project/ui/TestCaseSelectBar.tsx +++ b/src/features/project/ui/TestCaseSelectBar.tsx @@ -3,7 +3,7 @@ import type { OnDragEndResponder } from "@hello-pangea/dnd"; import type { StackProps } from "@mui/material"; import { useSnackbar } from "notistack"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { selectIsEditable } from "#/features/project/model/projectSlice"; import { DraggableSelectBarList } from "#/features/selectBar/ui/DraggableSelectBarList"; @@ -14,7 +14,7 @@ import { api } from "#/shared/api"; import type { RouterOutputs } from "#/shared/api"; import { usePlaygroundSlugs } from "#/shared/hooks"; import { useI18nContext } from "#/shared/hooks"; -import { useAppDispatch, useAppSelector } from "#/store/hooks"; +import { useAppSelector } from "#/store/hooks"; import { CaseModal } from "./CaseModal"; @@ -32,7 +32,6 @@ export const TestCaseSelectBar: React.FC<TestCaseSelectBarProps> = ({ ...restProps }) => { const { LL } = useI18nContext(); - const dispatch = useAppDispatch(); const { enqueueSnackbar } = useSnackbar(); @@ -95,16 +94,6 @@ export const TestCaseSelectBar: React.FC<TestCaseSelectBarProps> = ({ const isEditable = useAppSelector(selectIsEditable); - useEffect(() => { - if (caseSlug || !selectedProject.data) return; - - const firstCaseSlug = selectedProject.data.cases[0]?.slug; - - if (firstCaseSlug) { - setCase(firstCaseSlug); - } - }, [caseSlug, selectedProject.data, dispatch, setCase]); - const handleCaseClick = (testCase: TestCaseBrief) => { void setCase(testCase.slug); }; diff --git a/src/graphql/apolloClient.ts b/src/graphql/apolloClient.ts index bc5f9303..725b2350 100644 --- a/src/graphql/apolloClient.ts +++ b/src/graphql/apolloClient.ts @@ -1,57 +1,4 @@ -import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client"; -import { setContext } from "@apollo/client/link/context"; -import { getCookie } from "cookies-next"; +import { createApolloClient } from "#/graphql/createApolloClient"; -const httpLink = createHttpLink({ - uri: "/api/graphql", -}); - -// const testCookie = ''; - -// setCookie('LEETCODE_SESSION', testCookie); - -const authLink = setContext((_, { headers }) => { - const token = getCookie("LEETCODE_SESSION"); - // return the headers to the context so httpLink can read them - return { - headers: { - ...headers, - extToken: token, - }, - }; -}); - -const jsonParseRead = (field: string) => JSON.parse(field); - -const cache = new InMemoryCache({ - typePolicies: { - QuestionNode: { - fields: { - stats: { - read: (stats: string) => { - const result = jsonParseRead(stats); - result.acRate = parseFloat(result.acRate); - return result; - }, - }, - similarQuestions: { - read: jsonParseRead, - }, - envInfo: { - read: jsonParseRead, - }, - metaData: { - read: jsonParseRead, - }, - }, - }, - }, -}); - -const isApolloDevtoolsEnabled = process.env.NODE_ENV !== "production"; - -export const apolloClient = new ApolloClient({ - link: authLink.concat(httpLink), - cache, - devtools: { enabled: isApolloDevtoolsEnabled }, -}); +/** Browser singleton — daily/profile routes hydrate via {@link ApolloHydrationProvider}. */ +export const apolloClient = createApolloClient(); diff --git a/src/graphql/apolloInMemoryCache.ts b/src/graphql/apolloInMemoryCache.ts new file mode 100644 index 00000000..eb4b31ac --- /dev/null +++ b/src/graphql/apolloInMemoryCache.ts @@ -0,0 +1,29 @@ +import { InMemoryCache } from "@apollo/client"; + +const jsonParseRead = (field: string) => JSON.parse(field); + +export const createApolloInMemoryCache = (): InMemoryCache => + new InMemoryCache({ + typePolicies: { + QuestionNode: { + fields: { + stats: { + read: (stats: string) => { + const result = jsonParseRead(stats); + result.acRate = parseFloat(result.acRate); + return result; + }, + }, + similarQuestions: { + read: jsonParseRead, + }, + envInfo: { + read: jsonParseRead, + }, + metaData: { + read: jsonParseRead, + }, + }, + }, + }, + }); diff --git a/src/graphql/createApolloClient.ts b/src/graphql/createApolloClient.ts new file mode 100644 index 00000000..23dcfda6 --- /dev/null +++ b/src/graphql/createApolloClient.ts @@ -0,0 +1,52 @@ +import { + ApolloClient, + createHttpLink, + type NormalizedCacheObject, +} from "@apollo/client"; +import { setContext } from "@apollo/client/link/context"; +import { getCookie } from "cookies-next"; + +import { createApolloInMemoryCache } from "#/graphql/apolloInMemoryCache"; + +export type CreateApolloClientOptions = { + initialState?: NormalizedCacheObject; + /** Server-side fetch against LeetCode directly (no browser cookies). */ + ssr?: boolean; + leetCodeSession?: string | null; +}; + +export function createApolloClient( + options?: CreateApolloClientOptions, +): ApolloClient<NormalizedCacheObject> { + const httpLink = createHttpLink({ + uri: options?.ssr ? "https://leetcode.com/graphql/" : "/api/graphql", + }); + + const authLink = setContext((_, { headers }) => { + const token = options?.ssr + ? options.leetCodeSession + : getCookie("LEETCODE_SESSION"); + + return { + headers: { + ...headers, + ...(options?.ssr && token + ? { cookie: `LEETCODE_SESSION=${token}` } + : {}), + ...(!options?.ssr && token ? { extToken: token } : {}), + }, + }; + }); + + const client = new ApolloClient({ + link: authLink.concat(httpLink), + cache: createApolloInMemoryCache(), + devtools: { enabled: process.env.NODE_ENV !== "production" }, + }); + + if (options?.initialState) { + client.cache.restore(options.initialState); + } + + return client; +} diff --git a/src/server/api/routers/project.ts b/src/server/api/routers/project.ts index 6eeaba83..14cd6183 100644 --- a/src/server/api/routers/project.ts +++ b/src/server/api/routers/project.ts @@ -17,6 +17,7 @@ import { getDefaultCodeSnippets, getMergedCodeContent, } from "#/features/codeRunner/lib/getDefaultCodeSnippets"; +import { revalidatePublicPlaygroundProject } from "#/features/playground/lib/playgroundProjectSeoCache"; import { createTRPCRouter, protectedProcedure, @@ -525,8 +526,8 @@ export const projectRouter = createTRPCRouter({ isExample: z.boolean().optional(), }), ) - .mutation(async ({ input: data, ctx }) => - ctx.db.playgroundProject + .mutation(async ({ input: data, ctx }) => { + const created = await ctx.db.playgroundProject .create({ data: { ...data, @@ -561,8 +562,14 @@ export const projectRouter = createTRPCRouter({ }); } throw error; - }), - ), + }); + + if (created.isPublic) { + revalidatePublicPlaygroundProject(created.slug); + } + + return created; + }), update: projectOwnerProcedure .input( @@ -578,23 +585,46 @@ export const projectRouter = createTRPCRouter({ isExample: z.boolean().optional(), }), ) - .mutation(async ({ input: { projectId: id, ...data }, ctx }) => - ctx.db.playgroundProject + .mutation(async ({ input: { projectId: id, ...data }, ctx }) => { + const existing = await ctx.db.playgroundProject.findUnique({ + where: { id }, + select: { slug: true, isPublic: true }, + }); + + const updated = await ctx.db.playgroundProject .update({ where: { id, }, data, }) - .catch((error: any) => { - if (error.code === "P2002") { + .catch((error: unknown) => { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { throw new TRPCError({ code: "BAD_REQUEST", message: "You already have a project with this name.", }); - } else throw error; - }), - ), + } + throw error; + }); + + if (existing?.isPublic || updated.isPublic) { + revalidatePublicPlaygroundProject(existing?.slug ?? updated.slug); + } + if ( + data.slug && + existing && + data.slug !== existing.slug && + updated.isPublic + ) { + revalidatePublicPlaygroundProject(data.slug); + } + + return updated; + }), delete: projectOwnerProcedure .input( @@ -603,22 +633,44 @@ export const projectRouter = createTRPCRouter({ }), ) .mutation(async ({ input: { projectId }, ctx }) => { + const existing = await ctx.db.playgroundProject.findUnique({ + where: { id: projectId }, + select: { slug: true, isPublic: true }, + }); + void clearProjectEntities(projectId); - return ctx.db.playgroundProject.delete({ + const deleted = await ctx.db.playgroundProject.delete({ where: { id: projectId, }, }); + + if (existing?.isPublic) { + revalidatePublicPlaygroundProject(existing.slug); + } + + return deleted; }), // Delete all personal projects - deleteAll: protectedProcedure.mutation(async ({ ctx }) => - ctx.db.playgroundProject.deleteMany({ + deleteAll: protectedProcedure.mutation(async ({ ctx }) => { + const projects = await ctx.db.playgroundProject.findMany({ + where: { userId: ctx.session.user.id, isPublic: true }, + select: { slug: true }, + }); + + const result = await ctx.db.playgroundProject.deleteMany({ where: { userId: ctx.session.user.id, }, - }), - ), + }); + + for (const project of projects) { + revalidatePublicPlaygroundProject(project.slug); + } + + return result; + }), getCaseBySlug: publicProcedure .input( @@ -628,11 +680,24 @@ export const projectRouter = createTRPCRouter({ }), ) .query(async ({ input, ctx }) => - ctx.db.playgroundTestCase.findUniqueOrThrow({ - where: { - projectId_slug: input, - }, - }), + ctx.db.playgroundTestCase + .findUniqueOrThrow({ + where: { + projectId_slug: input, + }, + }) + .catch((error: unknown) => { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2025" + ) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Case "${input.slug}" not found.`, + }); + } + throw error; + }), ), addCase: projectOwnerProcedure @@ -753,14 +818,27 @@ export const projectRouter = createTRPCRouter({ }), ) .query(async ({ input, ctx }) => { - const solution = await ctx.db.playgroundSolution.findUniqueOrThrow({ - where: { - projectId_slug: input, - }, - include: { - project: { select: { category: true } }, - }, - }); + const solution = await ctx.db.playgroundSolution + .findUniqueOrThrow({ + where: { + projectId_slug: input, + }, + include: { + project: { select: { category: true } }, + }, + }) + .catch((error: unknown) => { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2025" + ) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Solution "${input.slug}" not found.`, + }); + } + throw error; + }); const { project, ...rest } = solution; const mergedCode = getMergedCodeContent(project.category, { code: rest.code, diff --git a/src/server/daily/getDailyInitialData.ts b/src/server/daily/getDailyInitialData.ts new file mode 100644 index 00000000..81d9bcee --- /dev/null +++ b/src/server/daily/getDailyInitialData.ts @@ -0,0 +1,41 @@ +import type { NormalizedCacheObject } from "@apollo/client"; + +import { createApolloClient } from "#/graphql/createApolloClient"; +import { + QuestionDataDocument, + QuestionOfTodayDocument, +} from "#/graphql/generated"; + +/** + * Prefetch today's LeetCode daily question for RSC + Apollo cache hydration. + * Returns null when upstream GraphQL is unavailable (client hooks refetch). + */ +export async function getDailyInitialData(): Promise<NormalizedCacheObject | null> { + const client = createApolloClient({ ssr: true }); + + try { + const todayResult = await client.query({ + query: QuestionOfTodayDocument, + fetchPolicy: "no-cache", + }); + + const titleSlug = + todayResult.data.activeDailyCodingChallengeQuestion?.question?.titleSlug; + + if (titleSlug) { + await client.query({ + query: QuestionDataDocument, + variables: { titleSlug }, + fetchPolicy: "no-cache", + }); + } + + return client.cache.extract(); + } catch (error) { + console.warn( + "getDailyInitialData: LeetCode GraphQL prefetch failed", + error, + ); + return null; + } +} diff --git a/src/server/playground/__tests__/getPlaygroundInitialData.test.ts b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts new file mode 100644 index 00000000..9b1bd928 --- /dev/null +++ b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts @@ -0,0 +1,125 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockAllBrief = vi.fn(); +const mockGetBySlug = vi.fn(); +const mockGetCaseBySlug = vi.fn(); +const mockGetSolutionBySlug = vi.fn(); + +vi.mock("#/server/auth/authOptions", () => ({ + authOptions: {}, +})); + +vi.mock("#/server/api/root", () => ({ + createCaller: () => ({ + project: { + allBrief: mockAllBrief, + getBySlug: mockGetBySlug, + getCaseBySlug: mockGetCaseBySlug, + getSolutionBySlug: mockGetSolutionBySlug, + }, + }), +})); + +vi.mock("#/server/api/context", () => ({ + createInnerTRPCContext: async (opts: unknown) => opts, +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn().mockResolvedValue(null), +})); + +describe("getPlaygroundInitialData", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAllBrief.mockResolvedValue([{ id: "1", slug: "demo", title: "Demo" }]); + mockGetBySlug.mockResolvedValue({ + id: "proj-1", + slug: "two-sum", + title: "Two Sum", + }); + mockGetCaseBySlug.mockResolvedValue({ + id: "case-1", + slug: "case-a", + projectId: "proj-1", + }); + mockGetSolutionBySlug.mockResolvedValue({ + id: "solution-1", + slug: "solution-a", + projectId: "proj-1", + code: "", + pythonCode: "", + }); + }); + + it("returns allBrief only when no slug is provided", async () => { + const { getPlaygroundInitialData } = + await import("#/server/playground/getPlaygroundInitialData"); + const result = await getPlaygroundInitialData(); + + expect(result.allBrief).toHaveLength(1); + expect(result.projectBySlug).toBeNull(); + expect(result.caseBySlug).toBeNull(); + expect(result.solutionBySlug).toBeNull(); + expect(mockGetBySlug).not.toHaveBeenCalled(); + }); + + it("prefetches project and case when slugs are provided", async () => { + const { getPlaygroundInitialData } = + await import("#/server/playground/getPlaygroundInitialData"); + const result = await getPlaygroundInitialData("two-sum", "case-a"); + + expect(mockGetBySlug).toHaveBeenCalledWith("two-sum"); + expect(mockGetCaseBySlug).toHaveBeenCalledWith({ + projectId: "proj-1", + slug: "case-a", + }); + expect(result.projectBySlug?.slug).toBe("two-sum"); + expect(result.caseBySlug?.slug).toBe("case-a"); + expect(result.solutionBySlug).toBeNull(); + }); + + it("prefetches solution when solution slug is provided", async () => { + const { getPlaygroundInitialData } = + await import("#/server/playground/getPlaygroundInitialData"); + const result = await getPlaygroundInitialData( + "two-sum", + "case-a", + "solution-a", + ); + + expect(mockGetSolutionBySlug).toHaveBeenCalledWith({ + projectId: "proj-1", + slug: "solution-a", + }); + expect(result.solutionBySlug?.slug).toBe("solution-a"); + }); + + it("keeps project prefetch when case slug is invalid", async () => { + mockGetCaseBySlug.mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Case not found." }), + ); + + const { getPlaygroundInitialData } = + await import("#/server/playground/getPlaygroundInitialData"); + const result = await getPlaygroundInitialData("two-sum", "missing-case"); + + expect(result.projectBySlug?.slug).toBe("two-sum"); + expect(result.caseBySlug).toBeNull(); + }); + + it("returns null project when project slug is invalid", async () => { + mockGetBySlug.mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Project not found." }), + ); + + const { getPlaygroundInitialData } = + await import("#/server/playground/getPlaygroundInitialData"); + const result = await getPlaygroundInitialData("missing-project"); + + expect(result.allBrief).toHaveLength(1); + expect(result.projectBySlug).toBeNull(); + expect(result.caseBySlug).toBeNull(); + expect(result.solutionBySlug).toBeNull(); + }); +}); diff --git a/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts b/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts new file mode 100644 index 00000000..438e23c7 --- /dev/null +++ b/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockAllBrief = vi.fn(); +const mockGetBySlug = vi.fn(); +const mockLoadCachedPublicProjectsBrief = vi.fn(); + +vi.mock("#/server/auth/authOptions", () => ({ + authOptions: {}, +})); + +vi.mock("#/server/api/root", () => ({ + createCaller: () => ({ + project: { + allBrief: mockAllBrief, + getBySlug: mockGetBySlug, + }, + }), +})); + +vi.mock("#/server/api/context", () => ({ + createInnerTRPCContext: async (opts: unknown) => opts, +})); + +vi.mock("#/server/playground/loadCachedPublicProjectsBrief", () => ({ + loadCachedPublicProjectsBrief: () => mockLoadCachedPublicProjectsBrief(), +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn().mockResolvedValue(null), +})); + +describe("resolveCanonicalPlaygroundRedirect", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadCachedPublicProjectsBrief.mockResolvedValue([ + { id: "1", slug: "two-sum", title: "Two Sum" }, + ]); + mockAllBrief.mockResolvedValue([ + { id: "1", slug: "two-sum", title: "Two Sum" }, + ]); + mockGetBySlug.mockResolvedValue({ + id: "proj-1", + slug: "two-sum", + cases: [{ slug: "case-1" }], + solutions: [{ slug: "solution-1" }], + }); + }); + + it("keeps bare /playground indexable when there is no last-visit cookie", async () => { + const { resolveCanonicalPlaygroundRedirect } = + await import("#/server/playground/resolveCanonicalPlaygroundRedirect"); + + const redirectPath = await resolveCanonicalPlaygroundRedirect({ + basePath: "/playground", + slug: [], + lastPathCookie: null, + }); + + expect(redirectPath).toBeNull(); + expect(mockLoadCachedPublicProjectsBrief).not.toHaveBeenCalled(); + }); + + it("restores the last path from cookie when landing on bare /playground", async () => { + mockGetBySlug.mockResolvedValue({ + id: "proj-2", + slug: "three-sum", + cases: [{ slug: "case-a" }], + solutions: [{ slug: "solution-a" }], + }); + + const { resolveCanonicalPlaygroundRedirect } = + await import("#/server/playground/resolveCanonicalPlaygroundRedirect"); + + const redirectPath = await resolveCanonicalPlaygroundRedirect({ + basePath: "/playground", + slug: [], + lastPathCookie: "/playground/three-sum/case-a/solution-a", + }); + + expect(redirectPath).toBe("/playground/three-sum/case-a/solution-a"); + }); + + it("returns null when the URL is already canonical", async () => { + const { resolveCanonicalPlaygroundRedirect } = + await import("#/server/playground/resolveCanonicalPlaygroundRedirect"); + + const redirectPath = await resolveCanonicalPlaygroundRedirect({ + basePath: "/playground", + slug: ["two-sum", "case-1", "solution-1"], + lastPathCookie: null, + }); + + expect(redirectPath).toBeNull(); + }); + + it("appends ?view=code on mobile when canonicalizing a project path", async () => { + const { resolveCanonicalPlaygroundRedirect } = + await import("#/server/playground/resolveCanonicalPlaygroundRedirect"); + + const redirectPath = await resolveCanonicalPlaygroundRedirect({ + basePath: "/playground", + slug: ["two-sum"], + lastPathCookie: null, + ssrDeviceType: "mobile", + }); + + expect(redirectPath).toBe( + "/playground/two-sum/case-1/solution-1?view=code", + ); + }); +}); diff --git a/src/server/playground/getPlaygroundInitialData.ts b/src/server/playground/getPlaygroundInitialData.ts new file mode 100644 index 00000000..d7bd15a7 --- /dev/null +++ b/src/server/playground/getPlaygroundInitialData.ts @@ -0,0 +1,127 @@ +import { TRPCError } from "@trpc/server"; +import { getServerSession } from "next-auth"; + +import { createInnerTRPCContext } from "#/server/api/context"; +import { createCaller } from "#/server/api/root"; +import { authOptions } from "#/server/auth/authOptions"; +import type { RouterOutputs } from "#/shared/api"; + +export type PlaygroundInitialData = { + allBrief: RouterOutputs["project"]["allBrief"]; + projectBySlug: RouterOutputs["project"]["getBySlug"] | null; + caseBySlug: RouterOutputs["project"]["getCaseBySlug"] | null; + solutionBySlug: RouterOutputs["project"]["getSolutionBySlug"] | null; +}; + +export type ProjectBySlug = RouterOutputs["project"]["getBySlug"]; + +export async function loadProjectBySlug( + caller: ReturnType<typeof createCaller>, + projectSlug: string, +): Promise<ProjectBySlug | null> { + try { + return await caller.project.getBySlug(projectSlug); + } catch (error) { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return null; + } + throw error; + } +} + +async function loadSolutionBySlug( + caller: ReturnType<typeof createCaller>, + projectId: string, + solutionSlug: string, +): Promise<RouterOutputs["project"]["getSolutionBySlug"] | null> { + try { + return await caller.project.getSolutionBySlug({ + projectId, + slug: solutionSlug, + }); + } catch (error) { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return null; + } + throw error; + } +} + +/** + * Server-prefetch public playground lists and the active project/case/solution for RSC pages. + * Hydrates client tRPC queries via {@link PlaygroundInitialDataProvider}. + * + * Uses live tRPC `allBrief` (not `'use cache'`) so props stay plain objects for the client. + */ +export async function getPlaygroundInitialData( + projectSlug?: string, + caseSlug?: string, + solutionSlug?: string, +): Promise<PlaygroundInitialData> { + const session = await getServerSession(authOptions); + const caller = createCaller( + await createInnerTRPCContext({ + session, + }), + ); + + if (!projectSlug) { + const allBrief = await caller.project.allBrief(); + return { + allBrief, + projectBySlug: null, + caseBySlug: null, + solutionBySlug: null, + }; + } + + const [allBrief, projectBySlug] = await Promise.all([ + caller.project.allBrief(), + loadProjectBySlug(caller, projectSlug), + ]); + + if (!projectBySlug) { + return { + allBrief, + projectBySlug: null, + caseBySlug: null, + solutionBySlug: null, + }; + } + + if (!caseSlug) { + return { + allBrief, + projectBySlug, + caseBySlug: null, + solutionBySlug: null, + }; + } + + let caseBySlug: RouterOutputs["project"]["getCaseBySlug"] | null = null; + + try { + caseBySlug = await caller.project.getCaseBySlug({ + projectId: projectBySlug.id, + slug: caseSlug, + }); + } catch (caseError) { + if (caseError instanceof TRPCError && caseError.code === "NOT_FOUND") { + caseBySlug = null; + } else { + throw caseError; + } + } + + if (!solutionSlug) { + return { allBrief, projectBySlug, caseBySlug, solutionBySlug: null }; + } + + const solutionBySlug = await loadSolutionBySlug( + caller, + projectBySlug.id, + solutionSlug, + ); + + return { allBrief, projectBySlug, caseBySlug, solutionBySlug }; +} diff --git a/src/server/playground/loadCachedPublicProjectsBrief.ts b/src/server/playground/loadCachedPublicProjectsBrief.ts new file mode 100644 index 00000000..37d5ef9c --- /dev/null +++ b/src/server/playground/loadCachedPublicProjectsBrief.ts @@ -0,0 +1,67 @@ +import { cacheLife, cacheTag } from "next/cache"; + +import { + calculateIsNew, + getNewProjectMarginMs, +} from "#/entities/projectEntity/lib/calculateIsNew"; +import { PUBLIC_PROJECTS_BRIEF_CACHE_TAG } from "#/features/playground/lib/playgroundCacheTags"; +import { db } from "#/server/db/client"; +import type { RouterOutputs } from "#/shared/api"; + +export { PUBLIC_PROJECTS_BRIEF_CACHE_TAG }; + +type PublicProjectsBrief = RouterOutputs["project"]["allBrief"]; + +async function queryPublicProjectsBrief(): Promise<PublicProjectsBrief> { + const projects = await db.playgroundProject.findMany({ + where: { isPublic: true }, + select: { + id: true, + createdAt: true, + slug: true, + title: true, + category: true, + difficulty: true, + author: { + select: { + id: true, + name: true, + bucketImage: true, + }, + }, + }, + orderBy: [{ category: "asc" }, { title: "asc" }], + }); + + const newProjectMarginMs = await getNewProjectMarginMs(); + + return projects.map((project) => ({ + id: project.id, + createdAt: project.createdAt, + slug: project.slug, + title: project.title, + category: project.category, + difficulty: project.difficulty, + author: project.author + ? { + id: project.author.id, + name: project.author.name, + bucketImage: project.author.bucketImage, + } + : null, + isNew: calculateIsNew(project.createdAt, newProjectMarginMs), + })); +} + +/** + * Cached anonymous project list for server-only redirects (not client props). + * `'use cache'` results must be plain objects — strip cache metadata before return. + */ +export async function loadCachedPublicProjectsBrief(): Promise<PublicProjectsBrief> { + "use cache"; + cacheLife("hours"); + cacheTag(PUBLIC_PROJECTS_BRIEF_CACHE_TAG); + + const brief = await queryPublicProjectsBrief(); + return JSON.parse(JSON.stringify(brief)) as PublicProjectsBrief; +} diff --git a/src/server/playground/resolveCanonicalPlaygroundRedirect.ts b/src/server/playground/resolveCanonicalPlaygroundRedirect.ts new file mode 100644 index 00000000..d860ba71 --- /dev/null +++ b/src/server/playground/resolveCanonicalPlaygroundRedirect.ts @@ -0,0 +1,148 @@ +import { getServerSession } from "next-auth"; + +import { createInnerTRPCContext } from "#/server/api/context"; +import { createCaller } from "#/server/api/root"; +import { authOptions } from "#/server/auth/authOptions"; +import type { RouterOutputs } from "#/shared/api"; +import { appendPlaygroundMobileViewQuery } from "#/shared/lib/appendPlaygroundMobileViewQuery"; +import { + buildCanonicalPlaygroundSlug, + playgroundSlugKey, +} from "#/shared/lib/buildCanonicalPlaygroundSlug"; +import { getRestorablePlaygroundPath } from "#/shared/lib/playgroundLastPath"; +import { + buildPlaygroundPath, + parsePlaygroundPathname, +} from "#/shared/lib/playgroundRoute"; +import type { SsrDeviceType } from "#/themes"; + +import { loadProjectBySlug } from "./getPlaygroundInitialData"; +import { loadCachedPublicProjectsBrief } from "./loadCachedPublicProjectsBrief"; + +type ProjectBySlug = RouterOutputs["project"]["getBySlug"]; + +async function createPlaygroundCaller() { + const session = await getServerSession(authOptions); + return createCaller( + await createInnerTRPCContext({ + session, + }), + ); +} + +async function resolveProjectSlug( + caller: ReturnType<typeof createCaller>, + slug: string[], + lastPathCookie: string | null, + basePath: string, +): Promise<ProjectBySlug | null> { + const [projectSlug] = slug; + + if (projectSlug) { + const project = await loadProjectBySlug(caller, projectSlug); + if (project) { + return project; + } + } + + const restoredPath = getRestorablePlaygroundPath(lastPathCookie, basePath); + const restoredParsed = restoredPath + ? parsePlaygroundPathname(restoredPath) + : null; + const restoredProjectSlug = restoredParsed?.slug[0]; + + if (restoredProjectSlug) { + const restoredProject = await loadProjectBySlug( + caller, + restoredProjectSlug, + ); + if (restoredProject) { + return restoredProject; + } + } + + const allBrief = await loadCachedPublicProjectsBrief(); + const firstProjectSlug = allBrief[0]?.slug; + if (!firstProjectSlug) { + return null; + } + + return loadProjectBySlug(caller, firstProjectSlug); +} + +export type ResolveCanonicalPlaygroundRedirectInput = { + basePath: string; + slug: string[]; + lastPathCookie: string | null; + ssrDeviceType?: SsrDeviceType; + viewParam?: string | null; +}; + +/** + * Returns a canonical playground pathname when URL segments are incomplete, + * or null when the current path already matches the canonical slug. + */ +function appendMobileViewQuery( + path: string, + canonicalSlug: string[], + ssrDeviceType: SsrDeviceType | undefined, + viewParam: string | null | undefined, +): string { + return appendPlaygroundMobileViewQuery(path, { + isMobile: ssrDeviceType === "mobile", + hasViewParam: Boolean(viewParam), + hasCanonicalSlug: canonicalSlug.length > 0, + }); +} + +export async function resolveCanonicalPlaygroundRedirect({ + basePath, + slug, + lastPathCookie, + ssrDeviceType, + viewParam, +}: ResolveCanonicalPlaygroundRedirectInput): Promise<string | null> { + const restoredPath = getRestorablePlaygroundPath(lastPathCookie, basePath); + + // Bare `/playground` stays indexable for SEO; only restore when a valid cookie exists. + if (slug.length === 0 && !restoredPath) { + return null; + } + + const caller = await createPlaygroundCaller(); + const project = await resolveProjectSlug( + caller, + slug, + lastPathCookie, + basePath, + ); + + if (!project) { + return null; + } + + const restoredParsed = restoredPath + ? parsePlaygroundPathname(restoredPath) + : null; + const useRestoredSegments = + !slug[0] && restoredParsed?.slug[0] === project.slug; + + const [, caseSlug = "", solutionSlug = ""] = slug; + const canonicalSlug = buildCanonicalPlaygroundSlug( + project, + useRestoredSegments ? (restoredParsed?.slug[1] ?? caseSlug) : caseSlug, + useRestoredSegments + ? (restoredParsed?.slug[2] ?? solutionSlug) + : solutionSlug, + ); + + const currentSlugKey = playgroundSlugKey(slug); + const canonicalSlugKey = playgroundSlugKey(canonicalSlug); + + if (currentSlugKey === canonicalSlugKey) { + return null; + } + + const path = buildPlaygroundPath(basePath, canonicalSlug); + return appendMobileViewQuery(path, canonicalSlug, ssrDeviceType, viewParam); +} diff --git a/src/server/playground/serializePlaygroundInitialData.ts b/src/server/playground/serializePlaygroundInitialData.ts new file mode 100644 index 00000000..325df844 --- /dev/null +++ b/src/server/playground/serializePlaygroundInitialData.ts @@ -0,0 +1,11 @@ +import type { PlaygroundInitialData } from "#/server/playground/getPlaygroundInitialData"; + +/** + * Strip non-serializable values (Dates, cache metadata symbols) before passing + * playground prefetch props to a Client Component boundary. + */ +export function serializePlaygroundInitialData( + initialData: PlaygroundInitialData, +): PlaygroundInitialData { + return JSON.parse(JSON.stringify(initialData)) as PlaygroundInitialData; +} diff --git a/src/server/profile/getProfileInitialData.ts b/src/server/profile/getProfileInitialData.ts new file mode 100644 index 00000000..6c3aa697 --- /dev/null +++ b/src/server/profile/getProfileInitialData.ts @@ -0,0 +1,39 @@ +import type { NormalizedCacheObject } from "@apollo/client"; + +import { createApolloClient } from "#/graphql/createApolloClient"; +import { GetUserProfileDocument } from "#/graphql/generated"; + +/** + * Prefetch LeetCode profile stats when the user has linked a LeetCode account. + * Returns null when username or session cookie is missing. + */ +export async function getProfileInitialData( + leetCodeUsername: string | null | undefined, + leetCodeSession: string | null | undefined, +): Promise<NormalizedCacheObject | null> { + const username = leetCodeUsername?.trim(); + if (!username) { + return null; + } + + const client = createApolloClient({ + ssr: true, + leetCodeSession: leetCodeSession ?? null, + }); + + try { + await client.query({ + query: GetUserProfileDocument, + variables: { username }, + fetchPolicy: "no-cache", + }); + + return client.cache.extract(); + } catch (error) { + console.warn( + "getProfileInitialData: LeetCode GraphQL prefetch failed", + error, + ); + return null; + } +} diff --git a/src/shared/hooks/usePlaygroundRoute.ts b/src/shared/hooks/usePlaygroundRoute.ts index 91fc9df8..23da13cc 100644 --- a/src/shared/hooks/usePlaygroundRoute.ts +++ b/src/shared/hooks/usePlaygroundRoute.ts @@ -6,7 +6,7 @@ import { usePathname, useSearchParams, } from "next/navigation"; -import { useCallback, useMemo } from "react"; +import { startTransition, useCallback, useMemo } from "react"; import { parsePlaygroundPathname } from "#/shared/lib/playgroundRoute"; @@ -64,11 +64,13 @@ export const usePlaygroundRoute = (): PlaygroundRouteContext | null => { options?: PlaygroundNavigateOptions, ) => { const href = `${targetPath}${buildAppQuerySuffix(options?.omitView)}`; - if (options?.replace) { - void appRouter.replace(href, { scroll: false }); - return; - } - void appRouter.push(href, { scroll: false }); + startTransition(() => { + if (options?.replace) { + void appRouter.replace(href, { scroll: false }); + return; + } + void appRouter.push(href, { scroll: false }); + }); }; return { diff --git a/src/shared/hooks/usePlaygroundSlugs.ts b/src/shared/hooks/usePlaygroundSlugs.ts index b5a4f011..7ec8ee9e 100644 --- a/src/shared/hooks/usePlaygroundSlugs.ts +++ b/src/shared/hooks/usePlaygroundSlugs.ts @@ -4,13 +4,8 @@ import { useEffect, useMemo } from "react"; import { projectSlice } from "#/features/project/model/projectSlice"; import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; +import { buildPlaygroundPath } from "#/shared/lib/playgroundRoute"; import { - buildPlaygroundPath, - parsePlaygroundPathname, -} from "#/shared/lib/playgroundRoute"; -import { - getLastPlaygroundPath, - getRestorablePlaygroundPath, removeLastPlaygroundPath, setLastPlaygroundPath, } from "#/shared/local-storage/playgroundPath"; @@ -43,7 +38,7 @@ export const usePlaygroundSlugs = () => { const [projectSlug, caseSlug, solutionSlug] = route.slug; const { basePath, navigateTo } = route; - const setProject = (slug?: string, isInitial?: boolean) => { + const setProject = (slug?: string) => { dispatch(projectSlice.actions.loadStart()); if (!slug) { return navigateTo(basePath, { @@ -52,19 +47,6 @@ export const usePlaygroundSlugs = () => { }); } - const lastPath = getLastPlaygroundPath(); - const lastParsed = lastPath ? parsePlaygroundPathname(lastPath) : null; - if (lastPath && !lastParsed) { - removeLastPlaygroundPath(); - } - const pathToRestore = isInitial - ? getRestorablePlaygroundPath(lastPath, basePath) - : null; - - if (pathToRestore) { - return navigateTo(pathToRestore, { replace: true, omitView: true }); - } - return navigateTo(buildPlaygroundPath(basePath, [slug]), { replace: true, omitView: true, diff --git a/src/shared/lib/__tests__/appendPlaygroundMobileViewQuery.test.ts b/src/shared/lib/__tests__/appendPlaygroundMobileViewQuery.test.ts new file mode 100644 index 00000000..2bebdf6a --- /dev/null +++ b/src/shared/lib/__tests__/appendPlaygroundMobileViewQuery.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { appendPlaygroundMobileViewQuery } from "#/shared/lib/appendPlaygroundMobileViewQuery"; + +describe("appendPlaygroundMobileViewQuery", () => { + it("appends ?view=code on mobile when canonicalizing without an existing view", () => { + expect( + appendPlaygroundMobileViewQuery("/playground/two-sum/case-1/solution-1", { + isMobile: true, + hasViewParam: false, + hasCanonicalSlug: true, + }), + ).toBe("/playground/two-sum/case-1/solution-1?view=code"); + }); + + it("leaves the path unchanged on desktop", () => { + expect( + appendPlaygroundMobileViewQuery("/playground/two-sum/case-1/solution-1", { + isMobile: false, + hasViewParam: false, + hasCanonicalSlug: true, + }), + ).toBe("/playground/two-sum/case-1/solution-1"); + }); + + it("does not append when a view param is already set", () => { + expect( + appendPlaygroundMobileViewQuery("/playground/two-sum/case-1/solution-1", { + isMobile: true, + hasViewParam: true, + hasCanonicalSlug: true, + }), + ).toBe("/playground/two-sum/case-1/solution-1"); + }); +}); diff --git a/src/shared/lib/__tests__/buildCanonicalPlaygroundSlug.test.ts b/src/shared/lib/__tests__/buildCanonicalPlaygroundSlug.test.ts new file mode 100644 index 00000000..35ebd42d --- /dev/null +++ b/src/shared/lib/__tests__/buildCanonicalPlaygroundSlug.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { buildCanonicalPlaygroundSlug } from "#/shared/lib/buildCanonicalPlaygroundSlug"; + +describe("buildCanonicalPlaygroundSlug", () => { + const project = { + slug: "two-sum", + cases: [{ slug: "case-1" }, { slug: "case-2" }], + solutions: [{ slug: "solution-1" }, { slug: "solution-2" }], + }; + + it("fills missing case and solution with first defaults", () => { + expect(buildCanonicalPlaygroundSlug(project)).toEqual([ + "two-sum", + "case-1", + "solution-1", + ]); + }); + + it("keeps a valid case slug and fills solution", () => { + expect(buildCanonicalPlaygroundSlug(project, "case-2")).toEqual([ + "two-sum", + "case-2", + "solution-1", + ]); + }); + + it("returns only project slug when there are no cases or solutions", () => { + expect( + buildCanonicalPlaygroundSlug({ + slug: "empty", + cases: [], + solutions: [], + }), + ).toEqual(["empty"]); + }); +}); diff --git a/src/shared/lib/appendPlaygroundMobileViewQuery.ts b/src/shared/lib/appendPlaygroundMobileViewQuery.ts new file mode 100644 index 00000000..64b095a8 --- /dev/null +++ b/src/shared/lib/appendPlaygroundMobileViewQuery.ts @@ -0,0 +1,15 @@ +/** Append `?view=code` for mobile canonical playground navigations when no view is set. */ +export function appendPlaygroundMobileViewQuery( + path: string, + options: { + isMobile: boolean; + hasViewParam: boolean; + hasCanonicalSlug: boolean; + }, +): string { + if (!options.isMobile || !options.hasCanonicalSlug || options.hasViewParam) { + return path; + } + + return `${path}?view=code`; +} diff --git a/src/shared/lib/buildCanonicalPlaygroundSlug.ts b/src/shared/lib/buildCanonicalPlaygroundSlug.ts new file mode 100644 index 00000000..91ab8760 --- /dev/null +++ b/src/shared/lib/buildCanonicalPlaygroundSlug.ts @@ -0,0 +1,68 @@ +type PlaygroundCaseBrief = { + slug: string; +}; + +type PlaygroundSolutionBrief = { + slug: string; +}; + +type PlaygroundProjectSlugSource = { + slug: string; + cases: PlaygroundCaseBrief[]; + solutions: PlaygroundSolutionBrief[]; +}; + +const pickCaseSlug = ( + project: PlaygroundProjectSlugSource, + caseSlug?: string, +): string | undefined => { + if ( + caseSlug && + project.cases.some((testCase) => testCase.slug === caseSlug) + ) { + return caseSlug; + } + + return project.cases[0]?.slug; +}; + +const pickSolutionSlug = ( + project: PlaygroundProjectSlugSource, + solutionSlug?: string, +): string | undefined => { + if ( + solutionSlug && + project.solutions.some((solution) => solution.slug === solutionSlug) + ) { + return solutionSlug; + } + + return project.solutions[0]?.slug; +}; + +/** + * Builds the canonical playground slug segments (project / case / solution) + * by filling missing segments with the first available defaults. + */ +export const buildCanonicalPlaygroundSlug = ( + project: PlaygroundProjectSlugSource, + caseSlug?: string, + solutionSlug?: string, +): string[] => { + const segments = [project.slug]; + + const resolvedCaseSlug = pickCaseSlug(project, caseSlug); + if (resolvedCaseSlug) { + segments.push(resolvedCaseSlug); + } + + const resolvedSolutionSlug = pickSolutionSlug(project, solutionSlug); + if (resolvedSolutionSlug) { + segments.push(resolvedSolutionSlug); + } + + return segments; +}; + +export const playgroundSlugKey = (slug: string[]): string => + slug.filter((segment) => segment.length > 0).join("/"); diff --git a/src/shared/lib/playgroundLastPath.ts b/src/shared/lib/playgroundLastPath.ts new file mode 100644 index 00000000..8abe67ed --- /dev/null +++ b/src/shared/lib/playgroundLastPath.ts @@ -0,0 +1,29 @@ +import { + parsePlaygroundPathname, + remapPlaygroundPathToBase, +} from "#/shared/lib/playgroundRoute"; + +/** Returns true when the path is a playground URL with a project slug. */ +export const isValidLastPlaygroundPath = (path: string | null): boolean => { + const parsed = path ? parsePlaygroundPathname(path) : null; + return Boolean(parsed?.slug[0]); +}; + +/** + * Returns a restorable path for the current playground base (public or locale-prefixed). + * Slug segments are preserved; only the prefix is remapped when `targetBasePath` is set. + */ +export const getRestorablePlaygroundPath = ( + path: string | null, + targetBasePath?: string, +): string | null => { + if (!isValidLastPlaygroundPath(path)) { + return null; + } + if (targetBasePath) { + return remapPlaygroundPathToBase(path!, targetBasePath); + } + const parsed = parsePlaygroundPathname(path!); + const projectSlug = parsed?.slug[0]; + return projectSlug?.startsWith("[[") ? null : path; +}; diff --git a/src/shared/lib/playgroundLastPathCookie.ts b/src/shared/lib/playgroundLastPathCookie.ts new file mode 100644 index 00000000..157b7432 --- /dev/null +++ b/src/shared/lib/playgroundLastPathCookie.ts @@ -0,0 +1,23 @@ +/** Cookie mirror of `lastPlaygroundPath` localStorage for server-side restore. */ +export const LAST_PLAYGROUND_PATH_COOKIE = "dstruct-last-playground-path"; + +const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365; + +/** Client-only: persist the last playground pathname for server restore on cold load. */ +export const setLastPlaygroundPathCookie = (pathname: string): void => { + if (typeof document === "undefined") { + return; + } + + const encoded = encodeURIComponent(pathname); + document.cookie = `${LAST_PLAYGROUND_PATH_COOKIE}=${encoded}; path=/; max-age=${ONE_YEAR_SECONDS}; samesite=lax`; +}; + +/** Client-only: clear the last playground pathname cookie. */ +export const clearLastPlaygroundPathCookie = (): void => { + if (typeof document === "undefined") { + return; + } + + document.cookie = `${LAST_PLAYGROUND_PATH_COOKIE}=; path=/; max-age=0; samesite=lax`; +}; diff --git a/src/shared/local-storage/playgroundPath.ts b/src/shared/local-storage/playgroundPath.ts index a439aab4..b81433ca 100644 --- a/src/shared/local-storage/playgroundPath.ts +++ b/src/shared/local-storage/playgroundPath.ts @@ -1,9 +1,15 @@ import { createStringStorage } from "#/shared/browser-storage"; import { - parsePlaygroundPathname, - PLAYGROUND_PUBLIC_BASE_PATH, - remapPlaygroundPathToBase, -} from "#/shared/lib/playgroundRoute"; + getRestorablePlaygroundPath, + isValidLastPlaygroundPath, +} from "#/shared/lib/playgroundLastPath"; +import { + clearLastPlaygroundPathCookie, + setLastPlaygroundPathCookie, +} from "#/shared/lib/playgroundLastPathCookie"; +import { PLAYGROUND_PUBLIC_BASE_PATH } from "#/shared/lib/playgroundRoute"; + +export { getRestorablePlaygroundPath, isValidLastPlaygroundPath }; export const PLAYGROUND_BASE_PATH = PLAYGROUND_PUBLIC_BASE_PATH; @@ -19,34 +25,10 @@ export const getLastPlaygroundPath = (): string | null => export const setLastPlaygroundPath = (path: string): void => { lastPlaygroundPathStorage.set(path); + setLastPlaygroundPathCookie(path); }; export const removeLastPlaygroundPath = (): void => { lastPlaygroundPathStorage.remove(); -}; - -/** - * Returns true if the path is a valid playground path with a project slug. - * Used to decide if we have a "last project" to show (e.g. default view). - */ -export const isValidLastPlaygroundPath = (path: string | null): boolean => { - const parsed = path ? parsePlaygroundPathname(path) : null; - return Boolean(parsed?.slug[0]); -}; - -/** - * Returns a restorable path for the current playground base (public or pilot). - * Slug segments are preserved; only the prefix is remapped when `targetBasePath` is set. - */ -export const getRestorablePlaygroundPath = ( - path: string | null, - targetBasePath?: string, -): string | null => { - if (!isValidLastPlaygroundPath(path)) return null; - if (targetBasePath) { - return remapPlaygroundPathToBase(path!, targetBasePath); - } - const parsed = parsePlaygroundPathname(path!); - const projectSlug = parsed?.slug[0]; - return projectSlug?.startsWith("[[") ? null : path; + clearLastPlaygroundPathCookie(); }; diff --git a/src/shared/ui/providers/AppShellProviders.tsx b/src/shared/ui/providers/AppShellProviders.tsx index 7daa2a6c..02bec578 100644 --- a/src/shared/ui/providers/AppShellProviders.tsx +++ b/src/shared/ui/providers/AppShellProviders.tsx @@ -1,10 +1,8 @@ "use client"; -import { ApolloProvider } from "@apollo/client"; import { SnackbarProvider } from "notistack"; import React, { type ReactNode } from "react"; -import { apolloClient } from "#/graphql/apolloClient"; import { TrpcProvider } from "#/shared/trpc/TrpcProvider"; import { SnackbarCloseButton } from "#/shared/ui/atoms/SnackbarCloseButton"; import { StateThemeProvider } from "#/shared/ui/providers/StateThemeProvider"; @@ -18,7 +16,8 @@ type AppShellProvidersProps = { }; /** - * Shared client provider stack for App Router layouts. + * Base client providers for all App Router layouts. + * Apollo mounts per-route via {@link ApolloHydrationProvider} on daily/profile pages. * SessionProvider is mounted in SessionGate (inside LocaleAppLayout). */ export const AppShellProviders: React.FC<AppShellProvidersProps> = ({ @@ -27,26 +26,23 @@ export const AppShellProviders: React.FC<AppShellProvidersProps> = ({ }) => ( <TrpcProvider> <ReduxProvider> - <ApolloProvider client={apolloClient}> - <StateThemeProvider ssrDeviceType={ssrDeviceType}> - <SnackbarProvider - maxSnack={4} - action={(snackbarKey) => - isSnackbarClosable(snackbarKey) ? ( - <SnackbarCloseButton snackbarKey={snackbarKey} /> - ) : null - } - classes={{ - containerAnchorOriginBottomLeft: "snackbar-mobile-bottom-margin", - containerAnchorOriginBottomCenter: - "snackbar-mobile-bottom-margin", - containerAnchorOriginBottomRight: "snackbar-mobile-bottom-margin", - }} - > - {children} - </SnackbarProvider> - </StateThemeProvider> - </ApolloProvider> + <StateThemeProvider ssrDeviceType={ssrDeviceType}> + <SnackbarProvider + maxSnack={4} + action={(snackbarKey) => + isSnackbarClosable(snackbarKey) ? ( + <SnackbarCloseButton snackbarKey={snackbarKey} /> + ) : null + } + classes={{ + containerAnchorOriginBottomLeft: "snackbar-mobile-bottom-margin", + containerAnchorOriginBottomCenter: "snackbar-mobile-bottom-margin", + containerAnchorOriginBottomRight: "snackbar-mobile-bottom-margin", + }} + > + {children} + </SnackbarProvider> + </StateThemeProvider> </ReduxProvider> </TrpcProvider> ); diff --git a/src/shared/ui/templates/PageScrollContainer.tsx b/src/shared/ui/templates/PageScrollContainer.tsx index 869e3374..9f84285c 100644 --- a/src/shared/ui/templates/PageScrollContainer.tsx +++ b/src/shared/ui/templates/PageScrollContainer.tsx @@ -6,11 +6,8 @@ import { import type { OverlayScrollbarsComponentProps } from "overlayscrollbars-react"; import React, { type Ref } from "react"; -import { - appBarSlice, - selectIsAppBarScrolled, -} from "#/features/appBar/model/appBarSlice"; -import { useAppDispatch, useAppSelector } from "#/store/hooks"; +import { appBarSlice } from "#/features/appBar/model/appBarSlice"; +import { useAppDispatch } from "#/store/hooks"; const assignViewportRef = ( viewportRef: Ref<HTMLDivElement | null> | undefined, @@ -55,7 +52,6 @@ export const PageScrollContainer: React.FC<PageScrollContainerProps> = ({ }) => { const dispatch = useAppDispatch(); const theme = useTheme(); - const isScrolled = useAppSelector(selectIsAppBarScrolled); const overlayScrollbarsRef = ( instance: OverlayScrollbarsComponentRef<"div"> | null, @@ -91,10 +87,7 @@ export const PageScrollContainer: React.FC<PageScrollContainerProps> = ({ isPage ? { scroll: (_, ev) => { - if ( - ev.target instanceof Element && - ev.target.scrollTop > 0 !== isScrolled - ) { + if (ev.target instanceof Element) { dispatch( appBarSlice.actions.setIsScrolled( ev.target.scrollTop > 0, diff --git a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout.tsx b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout.tsx index 728ebf10..3d6ea47c 100644 --- a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout.tsx +++ b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout.tsx @@ -4,6 +4,7 @@ import { Box } from "@mui/material"; import React, { useState } from "react"; import { Group, Panel, type PanelProps } from "react-resizable-panels"; +import { PLAYGROUND_DESKTOP_APP_BAR_HEIGHT } from "#/features/appBar/constants"; import { ResizeHandle } from "#/shared/ui/atoms/ResizeHandle"; export type SplitPanelsLayoutProps = { @@ -52,7 +53,7 @@ export const SplitPanelsLayout: React.FC<SplitPanelsLayoutProps> = ({ <Box component={component} sx={{ - height: "calc(100vh - 57px)", + height: `calc(100vh - ${PLAYGROUND_DESKTOP_APP_BAR_HEIGHT}px)`, width: "100vw", px: 1, pb: 1, diff --git a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx new file mode 100644 index 00000000..50de7c51 --- /dev/null +++ b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx @@ -0,0 +1,20 @@ +"use client"; + +import dynamic from "next/dynamic"; +import React from "react"; + +import { prefetchSplitPanelsLayout } from "#/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout"; +import type { SplitPanelsLayoutProps } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"; + +const SplitPanelsLayout = dynamic( + () => + prefetchSplitPanelsLayout().then((module) => ({ + default: module.SplitPanelsLayout, + })), + { ssr: false }, +); + +/** Client-only split layout — avoids Emotion hydration mismatch without a mount gate. */ +export const SplitPanelsLayoutClient: React.FC<SplitPanelsLayoutProps> = ( + props, +) => <SplitPanelsLayout {...props} />; diff --git a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton.tsx b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton.tsx index c584448a..d0828326 100644 --- a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton.tsx +++ b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton.tsx @@ -1,6 +1,8 @@ import { Box, Skeleton } from "@mui/material"; import React from "react"; +import { PLAYGROUND_DESKTOP_APP_BAR_HEIGHT } from "#/features/appBar/constants"; + function PanelSkeleton({ flex }: { flex: string }) { return ( <Skeleton @@ -44,7 +46,7 @@ export const SplitPanelsLayoutSkeleton: React.FC = () => { <Box component="main" sx={{ - height: "calc(100vh - 57px)", + height: `calc(100vh - ${PLAYGROUND_DESKTOP_APP_BAR_HEIGHT}px)`, width: "100vw", px: 1, pb: 1, diff --git a/src/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout.ts b/src/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout.ts new file mode 100644 index 00000000..c2e5d0f1 --- /dev/null +++ b/src/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout.ts @@ -0,0 +1,15 @@ +function loadSplitPanelsLayoutModule() { + return import("./SplitPanelsLayout"); +} + +let splitPanelsLayoutPromise: ReturnType< + typeof loadSplitPanelsLayoutModule +> | null = null; + +/** Warm the client-only split layout chunk (idempotent). */ +export const prefetchSplitPanelsLayout = (): ReturnType< + typeof loadSplitPanelsLayoutModule +> => { + splitPanelsLayoutPromise ??= loadSplitPanelsLayoutModule(); + return splitPanelsLayoutPromise; +}; diff --git a/vibe-docs/RSC-First-Refactor-Plan.md b/vibe-docs/RSC-First-Refactor-Plan.md new file mode 100644 index 00000000..85f25e74 --- /dev/null +++ b/vibe-docs/RSC-First-Refactor-Plan.md @@ -0,0 +1,101 @@ +# RSC-first App Router refactor — phased plan + +## Status (2026-09) + +**Phase 1 (done)** — PR #187 on `cursor/rsc-improvements-b999`: + +- Route groups `(marketing)` / `(app)`; Apollo only on data routes +- Privacy policy server-rendered (`PrivacyPageContent` RSC + `PrivacyPageShell` client chrome) +- Playground server prefetch (`getPlaygroundInitialData` + tRPC `initialData`) +- `LocaleAppPageShell` as Server Component; `ProjectBrowser` via `dynamic(..., { ssr: false })` +- `loading.tsx` for playground + profile instant routes +- Layout comment fix (session is resolved in layout, not Suspense-streamed) + +--- + +## Phase 2 — Cache + navigation polish (mostly done) + +| Item | Effort | Status | +|------|--------|--------| +| `'use cache'` on public playground SEO DB reads | Small | Done | +| `revalidateTag` on project admin mutations | Medium | Done | +| Session-aware private project SEO metadata | Medium | Done | +| `startTransition` on playground slug navigations | Small | Done | +| `loading.tsx` for `/daily` | Small | Done | +| Remove redundant playground `<Suspense>` (use `loading.tsx`) | Small | Done | +| Cached anonymous `allBrief` in server prefetch | Medium | Done | +| Server canonical playground redirect (project/case/solution) | Medium | Done | +| Apollo scoped to daily/profile only (not playground) | Medium | Done | +| Daily + profile GraphQL server prefetch | Medium | Done | +| `loading.tsx` for marketing `/` and `/privacy` | Small | Done | +| ProjectBrowser scoped to playground layout | Medium | Done | +| Mobile `?view=code` in server canonical redirect | Small | Done | + +--- + +## Phase 3 — Marketing RSC islands + +| Item | Effort | Notes | +|------|--------|-------| +| Split `MarketingHomeView` into RSC sections + client islands | Medium | Hero copy, FAQ, sections as server; 3D preview + scroll hooks client | +| `DailyPageView` shell as RSC | Medium | Server prefetch done; interactive bits remain client | +| Remove duplicate locale loads in page modules | Small | Prefer `loadI18nForLocale` / layout-passed `LL` everywhere | + +**Success criteria:** `/` and `/daily` ship meaningful HTML without waiting for client hydration; WebGL/Monaco remain client-only. + +--- + +## Phase 4 — Provider + data stack slimming + +| Item | Effort | Notes | +|------|--------|-------| +| Extract `MainAppBarProfileImageSync` (lazy, session-gated) | Small | Isolates tRPC mutation; does **not** remove `TrpcProvider` while signed-in users browse marketing | +| Server Action for profile image upload | Medium | Would allow dropping tRPC from marketing for signed-out users | +| tRPC RSC / `createCaller` prefetch helpers | Medium | Replace ad-hoc `getPlaygroundInitialData` with shared `prefetchProjectQueries` | +| Consolidate profile on tRPC **or** GraphQL (not both) | Large | Profile uses GraphQL; playground uses tRPC | + +**Success criteria:** Marketing routes mount fewer client providers when signed out; one server-fetch path per feature. + +--- + +## Phase 5 — Edge + Server Actions (optional) + +| Item | Effort | Notes | +|------|--------|-------| +| Edge Route Handlers for read-only public config | Medium | Only where latency wins are measurable | +| Server Actions for cookie consent / simple settings forms | Small | Progressive enhancement, `revalidateTag` | +| Broader `useTransition` on filters, project browser search | Small | Pair with Instant Nav | + +--- + +## Architecture target + +```mermaid +flowchart TB + subgraph marketing [Marketing routes] + MRSC[RSC page content] + MIsland[Client islands: 3D, session widget] + end + + subgraph app [Data routes] + Prefetch[Server prefetch + use cache] + Client[Playground / daily / profile clients] + end + + Base[Base providers: theme, Redux, tRPC, session] + Apollo[Apollo — daily/profile routes only] + + Base --> marketing + Base --> Apollo + Apollo --> app + Prefetch --> Client + MRSC --> MIsland +``` + +--- + +## References + +- `vibe-docs/Instant-Navigations-Design.md` — PPR / `instant = true` patterns +- `vibe-docs/App-Router-Migration-Plan.md` — migration complete (P6–P10) +- Review thread that spawned Phase 1 (2026-09)