Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import React, { Suspense } from "react";

import { resolvePlaygroundPageSeo } from "#/features/playground/lib/resolvePlaygroundPageSeo";
import { PlaygroundPageView } from "#/features/playground/ui/PlaygroundPageView";
import type { Locales } from "#/i18n/i18n-types";
import { locales } from "#/i18n/i18n-util";
import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton";

import { internalMarketingPilotMetadata } from "#/app/internal-marketing/internalMarketingPilotMetadata";

export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug?: string[] }>;
}): Promise<Metadata> {
const { locale: localeParam, slug } = await params;
if (!locales.includes(localeParam as Locales)) {
return { robots: { index: false, follow: false } };
}
const locale = localeParam as Locales;
const slugStr = slug?.[0];
const pagePath = slugStr ? `/playground/${slugStr}` : "/playground";
const { pageTitle, pageDescription } = await resolvePlaygroundPageSeo(
locale,
slugStr,
);

return internalMarketingPilotMetadata({
locale,
pagePath,
title: pageTitle,
description: pageDescription,
});
}

const PlaygroundPilotFallback: React.FC = () => <SplitPanelsLayoutSkeleton />;

/** Instant Nav pilot: playground shell (noindex; public `/playground` remains canonical). */
export default function InternalMarketingPlaygroundPage() {
return (
<Suspense fallback={<PlaygroundPilotFallback />}>
<PlaygroundPageView />
</Suspense>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ vi.mock("next/compat/router", () => ({
useRouter: vi.fn(),
}));

vi.mock("next/navigation", () => ({
usePathname: () => "/playground",
useParams: () => ({}),
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
}));

vi.mock("#/shared/hooks/useHasMounted", () => ({
useHasMounted: () => true,
}));
Expand Down
29 changes: 15 additions & 14 deletions src/features/playground/hooks/useMobilePlaygroundView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { useCallback, useEffect, useMemo } from "react";
import { callstackSlice } from "#/features/callstack/model/callstackSlice";
import { projectSlice } from "#/features/project/model/projectSlice";
import { useHasMounted, usePrevious } from "#/shared/hooks";
import { usePagesRouterCompat } from "#/shared/hooks/usePagesRouterCompat";
import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute";
import { buildPlaygroundPath } from "#/shared/lib/playgroundRoute";
import {
getLastPlaygroundPath,
isValidLastPlaygroundPath,
Expand All @@ -25,22 +26,19 @@ export type { PlaygroundView } from "./usePlaygroundViewParam";
*
* - **Implicit code with a slug**: if `view` is absent, shallow `replace` adds `?view=code`.
* - **Tab changes**: shallow `router.replace` (no extra history entries per tab tap).
* - Under App Router (no Pages router): returns browse defaults; no URL sync.
* - Under App Router pilot: uses {@link usePlaygroundRoute} for slug + `?view=` sync.
*/
export const useMobilePlaygroundView = () => {
const router = usePagesRouterCompat();
const route = usePlaygroundRoute();
const dispatch = useAppDispatch();
const hasMounted = useHasMounted();

const { view, setView } = usePlaygroundViewParam();

const hasProjectSlug = useMemo(() => {
if (!router) {
return false;
}
const slugs = router.query.slug;
return Array.isArray(slugs) && slugs.length > 0;
}, [router]);
const hasProjectSlug = useMemo(
() => route !== null && route.slug.length > 0,
[route],
);

const currentView: PlaygroundView = useMemo(() => {
if (view && isPlaygroundView(view)) {
Expand All @@ -66,12 +64,12 @@ export const useMobilePlaygroundView = () => {
// With a project slug, default UI is Code; mirror that in the URL when `view` is omitted
// (e.g. "Try it out", shared links, or `/playground/foo` without query).
useEffect(() => {
if (!router?.isReady) return;
if (route?.basePath === undefined) return;
if (!hasProjectSlug) return;
if (view !== "") return;

setView("code", { replace: true });
}, [router?.isReady, hasProjectSlug, view, setView, router]);
}, [hasProjectSlug, view, setView, route?.basePath]);

const navigateTo = useCallback(
(targetView: PlaygroundView, pathName?: string) => {
Expand All @@ -83,14 +81,17 @@ export const useMobilePlaygroundView = () => {
const goToBrowse = useCallback(() => navigateTo("browse"), [navigateTo]);
const goToCode = useCallback(
(projectSlug?: string) => {
const pathName = projectSlug ? `/playground/${projectSlug}` : undefined;
const pathName =
projectSlug && route
? buildPlaygroundPath(route.basePath, [projectSlug])
: undefined;

if (projectSlug) {
dispatch(projectSlice.actions.loadStart());
}
navigateTo("code", pathName);
},
[dispatch, navigateTo],
[dispatch, navigateTo, route],
);
const goToResults = useCallback(() => navigateTo("results"), [navigateTo]);

Expand Down
36 changes: 36 additions & 0 deletions src/features/playground/lib/resolvePlaygroundPageSeo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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";

export type PlaygroundPageSeo = {
pageTitle: string;
pageDescription: string;
};

/** Shared playground `<title>` / meta description for Pages gSSP and App metadata. */
export async function resolvePlaygroundPageSeo(
locale: Locales,
slugStr?: string,
): Promise<PlaygroundPageSeo> {
const translation = await importLocaleAsync(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 },
});
if (project) {
pageTitle = `${project.title} | dStruct`;
pageDescription = project.description?.trim()
? `${project.title}: ${project.description.trim()}`
: LL.PLAYGROUND_PROJECT_SEO_DESCRIPTION({ title: project.title });
}
}

return { pageTitle, pageDescription };
}
86 changes: 86 additions & 0 deletions src/features/playground/ui/PlaygroundPageView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"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 { MobilePlayground } from "#/features/playground/ui/MobilePlayground";
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";

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. */
export const PlaygroundPageView: React.FC = () => {
const theme = useTheme();
const isMobile = useMobileLayout();

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),
}}
>
{isMobile ? (
<PlaygroundViewProvider>
<MainAppBar toolbarVariant="dense" />
<MobilePlayground />
</PlaygroundViewProvider>
) : (
<>
<MainAppBar toolbarVariant="dense" />
<DesktopWrapper
TopLeft={ProjectPanel}
BottomLeft={CodePanel}
TopRight={TreeViewPanel}
BottomRight={OutputPanel}
/>
</>
)}
</PageScrollContainer>
</ConfigContext.Provider>
);
};
Loading
Loading