[WEB-5042] feat: sites vite migration (#7965)

This commit is contained in:
Prateek Shourya 2025-11-06 13:58:24 +05:30 committed by GitHub
parent 315e1d5eb0
commit 118ecc81ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 1062 additions and 739 deletions

View file

@ -1,9 +1,14 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
ignorePatterns: [
"build/**",
"dist/**",
".vite/**",
],
rules: {
"no-duplicate-imports": "off",
"import/no-duplicates": ["error", { "prefer-inline": false }],
"import/no-duplicates": ["error", {"prefer-inline": false}],
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"@typescript-eslint/no-import-type-side-effects": "error",
"@typescript-eslint/consistent-type-imports": [
@ -16,3 +21,5 @@ module.exports = {
],
},
};

View file

@ -12,6 +12,9 @@
/.next/
/out/
# react-router
/.react-router/
# production
/build

View file

@ -1,103 +1,86 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS base
# Setup pnpm package manager with corepack and configure global bin directory for caching
WORKDIR /app
ENV TURBO_TELEMETRY_DISABLED=1
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
ENV CI=1
RUN corepack enable pnpm
# =========================================================================== #
# *****************************************************************************
# STAGE 1: Build the project
# *****************************************************************************
FROM base AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /app
ARG TURBO_VERSION=2.5.6
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
RUN pnpm add -g turbo@2.5.8
COPY . .
# Create a pruned workspace for just the space app
RUN turbo prune --scope=space --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
# *****************************************************************************
# =========================================================================== #
FROM base AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Build in production mode; we still install dev deps explicitly below
ENV NODE_ENV=production
# Public envs required at build time (pick up via process.env)
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_API_BASE_PATH="/api"
ENV NEXT_PUBLIC_API_BASE_PATH=$NEXT_PUBLIC_API_BASE_PATH
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
ARG NEXT_PUBLIC_LIVE_BASE_URL=""
ENV NEXT_PUBLIC_LIVE_BASE_URL=$NEXT_PUBLIC_LIVE_BASE_URL
ARG NEXT_PUBLIC_LIVE_BASE_PATH="/live"
ENV NEXT_PUBLIC_LIVE_BASE_PATH=$NEXT_PUBLIC_LIVE_BASE_PATH
ARG NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ARG NEXT_PUBLIC_WEB_BASE_PATH=""
ENV NEXT_PUBLIC_WEB_BASE_PATH=$NEXT_PUBLIC_WEB_BASE_PATH
ARG NEXT_PUBLIC_WEBSITE_URL="https://plane.so"
ENV NEXT_PUBLIC_WEBSITE_URL=$NEXT_PUBLIC_WEBSITE_URL
ARG NEXT_PUBLIC_SUPPORT_EMAIL="support@plane.so"
ENV NEXT_PUBLIC_SUPPORT_EMAIL=$NEXT_PUBLIC_SUPPORT_EMAIL
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
# Fetch dependencies to cache store, then install offline with dev deps
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
ARG NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
# Build only the space package
RUN pnpm turbo run build --filter=space
# *****************************************************************************
# STAGE 3: Copy the project and start it
# *****************************************************************************
FROM base AS runner
WORKDIR /app
# =========================================================================== #
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
FROM nginx:1.27-alpine AS production
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer /app/apps/space/.next/standalone ./
COPY --from=installer /app/apps/space/.next/static ./apps/space/.next/static
COPY --from=installer /app/apps/space/public ./apps/space/public
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
ARG NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
COPY apps/space/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/space/build/client /usr/share/nginx/html/spaces
EXPOSE 3000
CMD ["node", "apps/space/server.js"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1
CMD ["nginx", "-g", "daemon off;"]

View file

@ -1,41 +0,0 @@
import { notFound, redirect } from "next/navigation";
// plane imports
import { SitesProjectPublishService } from "@plane/services";
import type { TProjectPublishSettings } from "@plane/types";
const publishService = new SitesProjectPublishService();
type Props = {
params: {
workspaceSlug: string;
projectId: string;
};
searchParams: Record<"board" | "peekId", string | string[] | undefined>;
};
export default async function IssuesPage(props: Props) {
const { params, searchParams } = props;
// query params
const { workspaceSlug, projectId } = params;
const { board, peekId } = searchParams;
let response: TProjectPublishSettings | undefined = undefined;
try {
response = await publishService.retrieveSettingsByProjectId(workspaceSlug, projectId);
} catch (error) {
console.error("Error fetching project publish settings:", error);
notFound();
}
let url = "";
if (response?.entity_name === "project") {
url = `/issues/${response?.anchor}`;
const params = new URLSearchParams();
if (board) params.append("board", String(board));
if (peekId) params.append("peekId", String(peekId));
if (params.toString()) url += `?${params.toString()}`;
redirect(url);
} else {
notFound();
}
}

View file

@ -0,0 +1,53 @@
"use client";
import { redirect } from "react-router";
import type { ClientLoaderFunctionArgs } from "react-router";
// plane imports
import { SitesProjectPublishService } from "@plane/services";
import type { TProjectPublishSettings } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
const publishService = new SitesProjectPublishService();
export const clientLoader = async ({ params, request }: ClientLoaderFunctionArgs) => {
const { workspaceSlug, projectId } = params;
// Validate required params
if (!workspaceSlug || !projectId) {
throw redirect("/404");
}
// Extract query params from the request URL
const url = new URL(request.url);
const board = url.searchParams.get("board");
const peekId = url.searchParams.get("peekId");
let response: TProjectPublishSettings | undefined = undefined;
try {
response = await publishService.retrieveSettingsByProjectId(workspaceSlug, projectId);
} catch {
throw redirect("/404");
}
if (response?.entity_name === "project") {
let redirectUrl = `/issues/${response?.anchor}`;
const urlParams = new URLSearchParams();
if (board) urlParams.append("board", String(board));
if (peekId) urlParams.append("peekId", String(peekId));
if (urlParams.toString()) redirectUrl += `?${urlParams.toString()}`;
throw redirect(redirectUrl);
} else {
throw redirect("/404");
}
};
export default function IssuesPage() {
return (
<div className="flex h-screen min-h-[500px] w-full justify-center items-center">
<LogoSpinner />
</div>
);
}

View file

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 466 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 761 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 954 KiB

After

Width:  |  Height:  |  Size: 954 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 418 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 438 B

After

Width:  |  Height:  |  Size: 438 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 742 B

After

Width:  |  Height:  |  Size: 742 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

View file

@ -0,0 +1,33 @@
/**
* Ensures that a URL has a trailing slash while preserving query parameters and fragments
* @param url - The URL to process
* @returns The URL with a trailing slash added to the pathname (if not already present)
*/
export function ensureTrailingSlash(url: string): string {
try {
// Handle relative URLs by creating a URL object with a dummy base
const urlObj = new URL(url, "http://dummy.com");
// Don't modify root path
if (urlObj.pathname === "/") {
return url;
}
// Add trailing slash if it doesn't exist
if (!urlObj.pathname.endsWith("/")) {
urlObj.pathname += "/";
}
// For relative URLs, return just the path + search + hash
if (url.startsWith("/")) {
return urlObj.pathname + urlObj.search + urlObj.hash;
}
// For absolute URLs, return the full URL
return urlObj.toString();
} catch (error) {
// If URL parsing fails, return the original URL
console.warn("Failed to parse URL for trailing slash enforcement:", url, error);
return url;
}
}

View file

@ -0,0 +1,14 @@
"use client";
import React from "react";
// Minimal shim so code using next/image compiles under React Router + Vite
// without changing call sites. It just renders a native img.
type NextImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
src: string;
};
const Image: React.FC<NextImageProps> = ({ src, alt = "", ...rest }) => <img src={src} alt={alt} {...rest} />;
export default Image;

View file

@ -0,0 +1,24 @@
"use client";
import React from "react";
import { Link as RRLink } from "react-router";
import { ensureTrailingSlash } from "./helper";
type NextLinkProps = React.ComponentProps<"a"> & {
href: string;
replace?: boolean;
prefetch?: boolean; // next.js prop, ignored
scroll?: boolean; // next.js prop, ignored
shallow?: boolean; // next.js prop, ignored
};
const Link: React.FC<NextLinkProps> = ({
href,
replace,
prefetch: _prefetch,
scroll: _scroll,
shallow: _shallow,
...rest
}) => <RRLink to={ensureTrailingSlash(href)} replace={replace} {...rest} />;
export default Link;

View file

@ -0,0 +1,38 @@
"use client";
import { useMemo } from "react";
import { useLocation, useNavigate, useParams as useParamsRR, useSearchParams as useSearchParamsRR } from "react-router";
import { ensureTrailingSlash } from "./helper";
export function useRouter() {
const navigate = useNavigate();
return useMemo(
() => ({
push: (to: string) => navigate(ensureTrailingSlash(to)),
replace: (to: string) => navigate(ensureTrailingSlash(to), { replace: true }),
back: () => navigate(-1),
forward: () => navigate(1),
refresh: () => {
location.reload();
},
prefetch: async (_to: string) => {
// no-op in this shim
},
}),
[navigate]
);
}
export function usePathname(): string {
const { pathname } = useLocation();
return pathname;
}
export function useSearchParams(): URLSearchParams {
const [searchParams] = useSearchParamsRR();
return searchParams;
}
export function useParams() {
return useParamsRR();
}

View file

@ -1,6 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { Outlet } from "react-router";
import useSWR from "swr";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
@ -10,14 +11,10 @@ import { IssuesNavbarRoot } from "@/components/issues/navbar";
// hooks
import { usePublish, usePublishList } from "@/hooks/store/publish";
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
import type { Route } from "./+types/client-layout";
type Props = {
children: React.ReactNode;
anchor: string;
};
export const IssuesClientLayout = observer((props: Props) => {
const { children, anchor } = props;
const IssuesClientLayout = observer((props: Route.ComponentProps) => {
const { anchor } = props.params;
// store hooks
const { fetchPublishSettings } = usePublishList();
const publishSettings = usePublish(anchor);
@ -57,9 +54,13 @@ export const IssuesClientLayout = observer((props: Props) => {
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
<IssuesNavbarRoot publishSettings={publishSettings} />
</div>
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div>
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">
<Outlet />
</div>
</div>
<PoweredBy />
</>
);
});
export default IssuesClientLayout;

View file

@ -1,7 +1,5 @@
"use server";
import { IssuesClientLayout } from "./client-layout";
type Props = {
children: React.ReactNode;
params: {
@ -9,6 +7,7 @@ type Props = {
};
};
// TODO: Convert into SSR in order to generate metadata
export async function generateMetadata({ params }: Props) {
const { anchor } = params;
const DEFAULT_TITLE = "Plane";
@ -49,9 +48,7 @@ export async function generateMetadata({ params }: Props) {
}
}
export default async function IssuesLayout(props: Props) {
const { children, params } = props;
const { anchor } = params;
return <IssuesClientLayout anchor={anchor}>{children}</IssuesClientLayout>;
export default async function IssuesLayout(_props: Props) {
// return <IssuesClientLayout params={{ anchor }}>{children}</IssuesClientLayout>;
return null;
}

View file

@ -1,7 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import useSWR from "swr";
// components
import { IssuesLayoutsRoot } from "@/components/issues/issue-layouts";
@ -10,16 +10,10 @@ import { usePublish } from "@/hooks/store/publish";
import { useLabel } from "@/hooks/store/use-label";
import { useStates } from "@/hooks/store/use-state";
type Props = {
params: {
anchor: string;
};
};
const IssuesPage = observer((props: Props) => {
const { params } = props;
const { anchor } = params;
const IssuesPage = observer(() => {
// params
const params = useParams<{ anchor: string }>();
const { anchor } = params;
const searchParams = useSearchParams();
const peekId = searchParams.get("peekId") || undefined;
// store

View file

@ -1,43 +0,0 @@
import type { Metadata } from "next";
// helpers
import { SPACE_BASE_PATH } from "@plane/constants";
// styles
import "@/styles/globals.css";
// components
import { AppProvider } from "./provider";
export const metadata: Metadata = {
title: "Plane Publish | Make your Plane boards public with one-click",
description: "Plane Publish is a customer feedback management tool built on top of plane.so",
openGraph: {
title: "Plane Publish | Make your Plane boards public with one-click",
description: "Plane Publish is a customer feedback management tool built on top of plane.so",
url: "https://sites.plane.so/",
},
keywords:
"software development, customer feedback, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration",
twitter: {
site: "@planepowers",
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link rel="apple-touch-icon" sizes="180x180" href={`${SPACE_BASE_PATH}/favicon/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${SPACE_BASE_PATH}/favicon/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${SPACE_BASE_PATH}/favicon/favicon-16x16.png`} />
<link rel="manifest" href={`${SPACE_BASE_PATH}/site.webmanifest.json`} />
<link rel="shortcut icon" href={`${SPACE_BASE_PATH}/favicon/favicon.ico`} />
<meta name="robots" content="noindex, nofollow" />
</head>
<body>
<div id="editor-portal" />
<AppProvider>
<>{children}</>
</AppProvider>
</body>
</html>
);
}

View file

@ -2,7 +2,7 @@
import Image from "next/image";
// assets
import SomethingWentWrongImage from "public/something-went-wrong.svg";
import SomethingWentWrongImage from "@/app/assets/something-went-wrong.svg?url";
const NotFound = () => (
<div className="h-screen w-screen grid place-items-center">

View file

@ -1,23 +1,18 @@
"use client";
import type { ReactNode, FC } from "react";
import { ThemeProvider } from "next-themes";
// components
import { TranslationProvider } from "@plane/i18n";
import { AppProgressBar } from "@/lib/b-progress";
import { InstanceProvider } from "@/lib/instance-provider";
import { StoreProvider } from "@/lib/store-provider";
import { ToastProvider } from "@/lib/toast-provider";
interface IAppProvider {
children: ReactNode;
}
export const AppProvider: FC<IAppProvider> = (props) => {
const { children } = props;
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem>
<StoreProvider>
<AppProgressBar />
<TranslationProvider>
<ToastProvider>
<InstanceProvider>{children}</InstanceProvider>
@ -26,4 +21,4 @@ export const AppProvider: FC<IAppProvider> = (props) => {
</StoreProvider>
</ThemeProvider>
);
};
}

66
apps/space/app/root.tsx Normal file
View file

@ -0,0 +1,66 @@
import { Links, Meta, Outlet, Scripts } from "react-router";
import type { LinksFunction } from "react-router";
// styles
import "@/styles/globals.css";
// assets
import appleTouchIcon from "@/app/assets/favicon/apple-touch-icon.png?url";
import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url";
import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url";
import faviconIco from "@/app/assets/favicon/favicon.ico?url";
// types
import type { Route } from "./+types/root";
// local imports
import ErrorPage from "./error";
import { AppProviders } from "./providers";
const APP_TITLE = "Plane Publish | Make your Plane boards public with one-click";
const APP_DESCRIPTION = "Plane Publish is a customer feedback management tool built on top of plane.so";
export const links: LinksFunction = () => [
{ rel: "apple-touch-icon", sizes: "180x180", href: appleTouchIcon },
{ rel: "icon", type: "image/png", sizes: "32x32", href: favicon32 },
{ rel: "icon", type: "image/png", sizes: "16x16", href: favicon16 },
{ rel: "shortcut icon", href: faviconIco },
{ rel: "manifest", href: `/site.webmanifest.json` },
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<Meta />
<Links />
</head>
<body>
<div id="editor-portal" />
<AppProviders>{children}</AppProviders>
<Scripts />
</body>
</html>
);
}
export const meta: Route.MetaFunction = () => [
{ title: APP_TITLE },
{ name: "description", content: APP_DESCRIPTION },
{ property: "og:title", content: APP_TITLE },
{ property: "og:description", content: APP_DESCRIPTION },
{ property: "og:url", content: "https://sites.plane.so/" },
{
name: "keywords",
content:
"software development, customer feedback, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration",
},
{ name: "twitter:site", content: "@planepowers" },
];
export default function Root() {
return <Outlet />;
}
export function ErrorBoundary() {
return <ErrorPage />;
}

10
apps/space/app/routes.ts Normal file
View file

@ -0,0 +1,10 @@
import type { RouteConfig } from "@react-router/dev/routes";
import { index, layout, route } from "@react-router/dev/routes";
export default [
index("./page.tsx"),
route(":workspaceSlug/:projectId", "./[workspaceSlug]/[projectId]/page.tsx"),
layout("./issues/[anchor]/client-layout.tsx", [route("issues/:anchor", "./issues/[anchor]/page.tsx")]),
// Catch-all route for 404 handling
route("*", "./not-found.tsx"),
] satisfies RouteConfig;

10
apps/space/app/types/next-image.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
declare module "next/image" {
import type { FC, ImgHTMLAttributes } from "react";
type NextImageProps = ImgHTMLAttributes<HTMLImageElement> & {
src: string;
};
const Image: FC<NextImageProps>;
export default Image;
}

14
apps/space/app/types/next-link.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
declare module "next/link" {
import type { FC, ComponentProps } from "react";
type NextLinkProps = ComponentProps<"a"> & {
href: string;
replace?: boolean;
prefetch?: boolean;
scroll?: boolean;
shallow?: boolean;
};
const Link: FC<NextLinkProps>;
export default Link;
}

View file

@ -0,0 +1,14 @@
declare module "next/navigation" {
export function useRouter(): {
push: (url: string) => void;
replace: (url: string) => void;
back: () => void;
forward: () => void;
refresh: () => void;
prefetch: (url: string) => Promise<void>;
};
export function usePathname(): string;
export function useSearchParams(): URLSearchParams;
export function useParams<T = Record<string, string>>(): T;
}

View file

@ -0,0 +1,5 @@
declare module "virtual:react-router/server-build" {
import type { ServerBuild } from "react-router";
const serverBuild: ServerBuild;
export default serverBuild;
}

View file

@ -1,65 +0,0 @@
"use client";
import { observer } from "mobx-react";
import useSWR from "swr";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PoweredBy } from "@/components/common/powered-by";
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
// hooks
import { usePublish, usePublishList } from "@/hooks/store/publish";
// Plane web
import { ViewNavbarRoot } from "@/plane-web/components/navbar";
import { useView } from "@/plane-web/hooks/store";
type Props = {
children: React.ReactNode;
params: {
anchor: string;
};
};
const ViewsLayout = observer((props: Props) => {
const { children, params } = props;
// params
const { anchor } = params;
// store hooks
const { fetchPublishSettings } = usePublishList();
const { viewData, fetchViewDetails } = useView();
const publishSettings = usePublish(anchor);
// fetch publish settings && view details
const { error } = useSWR(
anchor ? `PUBLISHED_VIEW_SETTINGS_${anchor}` : null,
anchor
? async () => {
const promises = [];
promises.push(fetchPublishSettings(anchor));
promises.push(fetchViewDetails(anchor));
await Promise.all(promises);
}
: null
);
if (error) return <SomethingWentWrongError />;
if (!publishSettings || !viewData) {
return (
<div className="flex items-center justify-center h-screen w-full">
<LogoSpinner />
</div>
);
}
return (
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
<ViewNavbarRoot publishSettings={publishSettings} />
</div>
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div>
<PoweredBy />
</div>
);
});
export default ViewsLayout;

View file

@ -1,37 +0,0 @@
"use client";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// components
import { PoweredBy } from "@/components/common/powered-by";
// hooks
import { usePublish } from "@/hooks/store/publish";
// plane-web
import { ViewLayoutsRoot } from "@/plane-web/components/issue-layouts/root";
type Props = {
params: {
anchor: string;
};
};
const ViewsPage = observer((props: Props) => {
const { params } = props;
const { anchor } = params;
// params
const searchParams = useSearchParams();
const peekId = searchParams.get("peekId") || undefined;
const publishSettings = usePublish(anchor);
if (!publishSettings) return null;
return (
<>
<ViewLayoutsRoot peekId={peekId} publishSettings={publishSettings} />
<PoweredBy />
</>
);
});
export default ViewsPage;

View file

@ -1,6 +1,5 @@
"use client";
import type { FC } from "react";
import { Info } from "lucide-react";
import { CloseIcon } from "@plane/propel/icons";
// helpers
@ -11,7 +10,7 @@ type TAuthBanner = {
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
export const AuthBanner: React.FC<TAuthBanner> = (props) => {
const { bannerData, handleBannerData } = props;
if (!bannerData) return <></>;

View file

@ -1,6 +1,5 @@
"use client";
import type { FC } from "react";
// helpers
import { EAuthModes } from "@/types/auth";
@ -28,7 +27,7 @@ const Titles: TAuthHeaderDetails = {
},
};
export const AuthHeader: FC<TAuthHeader> = (props) => {
export const AuthHeader: React.FC<TAuthHeader> = (props) => {
const { authMode } = props;
const getHeaderSubHeader = (mode: EAuthModes | null): TAuthHeaderContent => {

View file

@ -1,7 +1,6 @@
"use client";
import type { FC } from "react";
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
@ -11,7 +10,12 @@ import { API_BASE_URL } from "@plane/constants";
import { SitesAuthService } from "@plane/services";
import type { IEmailCheckData } from "@plane/types";
import { OAuthOptions } from "@plane/ui";
// components
// assets
import GiteaLogo from "@/app/assets/logos/gitea-logo.svg?url";
import GithubLightLogo from "@/app/assets/logos/github-black.png?url";
import GithubDarkLogo from "@/app/assets/logos/github-dark.svg?url";
import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
import GoogleLogo from "@/app/assets/logos/google-logo.svg?url";
// helpers
import type { TAuthErrorInfo } from "@/helpers/authentication.helper";
import { EErrorAlertType, authErrorHandler, EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
@ -19,12 +23,6 @@ import { EErrorAlertType, authErrorHandler, EAuthenticationErrorCodes } from "@/
import { useInstance } from "@/hooks/store/use-instance";
// types
import { EAuthModes, EAuthSteps } from "@/types/auth";
// assets
import GithubLightLogo from "/public/logos/github-black.png";
import GithubDarkLogo from "/public/logos/github-dark.svg";
import GitlabLogo from "/public/logos/gitlab-logo.svg";
import GoogleLogo from "/public/logos/google-logo.svg";
import GiteaLogo from "/public/logos/gitea-logo.svg";
// local imports
import { TermsAndConditions } from "../terms-and-conditions";
import { AuthBanner } from "./auth-banner";
@ -35,7 +33,7 @@ import { AuthUniqueCodeForm } from "./unique-code";
const authService = new SitesAuthService();
export const AuthRoot: FC = observer(() => {
export const AuthRoot: React.FC = observer(() => {
// router params
const searchParams = useSearchParams();
const emailParam = searchParams.get("email") || undefined;

View file

@ -1,6 +1,6 @@
"use client";
import type { FC, FormEvent } from "react";
import type { FormEvent } from "react";
import { useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
// icons
@ -19,7 +19,7 @@ type TAuthEmailForm = {
onSubmit: (data: IEmailCheckData) => Promise<void>;
};
export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
export const AuthEmailForm: React.FC<TAuthEmailForm> = observer((props) => {
const { onSubmit, defaultEmail } = props;
// states
const [isSubmitting, setIsSubmitting] = useState(false);

View file

@ -1,14 +1,12 @@
"use client";
import type { FC } from "react";
import React from "react";
import Link from "next/link";
type Props = {
isSignUp?: boolean;
};
export const TermsAndConditions: FC<Props> = (props) => {
export const TermsAndConditions: React.FC<Props> = (props) => {
const { isSignUp = false } = props;
return (
<span className="flex items-center justify-center py-6">

View file

@ -3,13 +3,13 @@
import { observer } from "mobx-react";
import Image from "next/image";
import { PlaneLockup } from "@plane/propel/icons";
// assets
import UserLoggedInImage from "@/app/assets/user-logged-in.svg?url";
// components
import { PoweredBy } from "@/components/common/powered-by";
import { UserAvatar } from "@/components/issues/navbar/user-avatar";
// hooks
import { useUser } from "@/hooks/store/use-user";
// assets
import UserLoggedInImage from "@/public/user-logged-in.svg";
export const UserLoggedIn = observer(() => {
// store hooks

View file

@ -2,8 +2,8 @@
import Image from "next/image";
import { useTheme } from "next-themes";
// assets
import LogoSpinnerDark from "@/public/images/logo-spinner-dark.gif";
import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif";
import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url";
import LogoSpinnerLight from "@/app/assets/images/logo-spinner-light.gif?url";
export const LogoSpinner = () => {
const { resolvedTheme } = useTheme();

View file

@ -1,6 +1,5 @@
"use client";
import type { FC } from "react";
import { WEBSITE_URL } from "@plane/constants";
// assets
import { PlaneLogo } from "@plane/propel/icons";
@ -9,7 +8,7 @@ type TPoweredBy = {
disabled?: boolean;
};
export const PoweredBy: FC<TPoweredBy> = (props) => {
export const PoweredBy: React.FC<TPoweredBy> = (props) => {
// props
const { disabled = false } = props;

View file

@ -1,14 +1,13 @@
"use client";
import type { FC } from "react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { Button } from "@plane/propel/button";
// assets
import InstanceFailureDarkImage from "public/instance/instance-failure-dark.svg";
import InstanceFailureImage from "public/instance/instance-failure.svg";
import InstanceFailureDarkImage from "@/app/assets/instance/instance-failure-dark.svg?url";
import InstanceFailureImage from "@/app/assets/instance/instance-failure.svg?url";
export const InstanceFailureView: FC = () => {
export const InstanceFailureView: React.FC = () => {
const { resolvedTheme } = useTheme();
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;

View file

@ -1,6 +1,5 @@
"use client";
import type { FC } from "react";
import { useCallback } from "react";
import { cloneDeep } from "lodash-es";
import { observer } from "mobx-react";
@ -16,7 +15,7 @@ type TIssueAppliedFilters = {
anchor: string;
};
export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) => {
export const IssueAppliedFilters: React.FC<TIssueAppliedFilters> = observer((props) => {
const { anchor } = props;
// router
const router = useRouter();

View file

@ -1,6 +1,5 @@
"use client";
import type { FC } from "react";
import { useCallback } from "react";
import { cloneDeep } from "lodash-es";
import { observer } from "mobx-react";
@ -21,7 +20,7 @@ type IssueFiltersDropdownProps = {
anchor: string;
};
export const IssueFiltersDropdown: FC<IssueFiltersDropdownProps> = observer((props) => {
export const IssueFiltersDropdown: React.FC<IssueFiltersDropdownProps> = observer((props) => {
const { anchor } = props;
// router
const router = useRouter();

View file

@ -1,6 +1,6 @@
import Image from "next/image";
// assets
import SomethingWentWrongImage from "public/something-went-wrong.svg";
import SomethingWentWrongImage from "@/app/assets/something-went-wrong.svg?url";
export const SomethingWentWrongError = () => (
<div className="grid min-h-screen w-full place-items-center p-6">

View file

@ -1,7 +1,5 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Circle } from "lucide-react";
// types
@ -14,7 +12,7 @@ interface IHeaderGroupByCard {
count: number;
}
export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
export const HeaderGroupByCard: React.FC<IHeaderGroupByCard> = observer((props) => {
const { icon, title, count } = props;
return (

View file

@ -1,5 +1,3 @@
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Circle } from "lucide-react";
import { ChevronDownIcon, ChevronUpIcon } from "@plane/propel/icons";
@ -13,7 +11,7 @@ interface IHeaderSubGroupByCard {
toggleExpanded: () => void;
}
export const HeaderSubGroupByCard: FC<IHeaderSubGroupByCard> = observer((props) => {
export const HeaderSubGroupByCard: React.FC<IHeaderSubGroupByCard> = observer((props) => {
const { icon, title, count, isExpanded, toggleExpanded } = props;
return (
<div

View file

@ -1,4 +1,4 @@
import type { FC, MutableRefObject } from "react";
import type { MutableRefObject } from "react";
// types
import type { IIssueDisplayProperties } from "@plane/types";
import { IssueBlock } from "./block";
@ -10,7 +10,7 @@ interface Props {
containerRef: MutableRefObject<HTMLDivElement | null>;
}
export const IssueBlocksList: FC<Props> = (props) => {
export const IssueBlocksList: React.FC<Props> = (props) => {
const { issueIds = [], groupId, displayProperties } = props;
return (

View file

@ -1,6 +1,5 @@
"use client";
import type { FC } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
@ -23,7 +22,7 @@ type Props = {
publishSettings: PublishStore;
};
export const IssuesLayoutsRoot: FC<Props> = observer((props) => {
export const IssuesLayoutsRoot: React.FC<Props> = observer((props) => {
const { peekId, publishSettings } = props;
// store hooks
const { getIssueFilters } = useIssueFilter();

View file

@ -1,4 +1,3 @@
import type { ReactNode } from "react";
import { observer } from "mobx-react";
// plane imports
import type { IIssueDisplayProperties } from "@plane/types";
@ -7,7 +6,7 @@ interface IWithDisplayPropertiesHOC {
displayProperties: IIssueDisplayProperties;
shouldRenderProperty?: (displayProperties: IIssueDisplayProperties) => boolean;
displayPropertyKey: keyof IIssueDisplayProperties | (keyof IIssueDisplayProperties)[];
children: ReactNode;
children: React.ReactNode;
}
export const WithDisplayPropertiesHOC = observer(

View file

@ -1,6 +1,5 @@
"use client";
import type { FC } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter, useSearchParams } from "next/navigation";
@ -25,7 +24,7 @@ export type NavbarControlsProps = {
publishSettings: PublishStore;
};
export const NavbarControls: FC<NavbarControlsProps> = observer((props) => {
export const NavbarControls: React.FC<NavbarControlsProps> = observer((props) => {
// props
const { publishSettings } = props;
// router

View file

@ -1,6 +1,5 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { useRouter, useSearchParams } from "next/navigation";
// ui
@ -20,7 +19,7 @@ type Props = {
anchor: string;
};
export const IssuesLayoutSelection: FC<Props> = observer((props) => {
export const IssuesLayoutSelection: React.FC<Props> = observer((props) => {
const { anchor } = props;
// hooks
const { t } = useTranslation();

View file

@ -1,6 +1,5 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { ProjectIcon } from "@plane/propel/icons";
// components
@ -14,7 +13,7 @@ type Props = {
publishSettings: PublishStore;
};
export const IssuesNavbarRoot: FC<Props> = observer((props) => {
export const IssuesNavbarRoot: React.FC<Props> = observer((props) => {
const { publishSettings } = props;
// hooks
const { project_details } = publishSettings;

View file

@ -1,6 +1,5 @@
"use client";
import type { FC } from "react";
import { Fragment, useEffect, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
@ -21,7 +20,7 @@ import { useUser } from "@/hooks/store/use-user";
const authService = new AuthService();
export const UserAvatar: FC = observer(() => {
export const UserAvatar: React.FC = observer(() => {
const pathName = usePathname();
const searchParams = useSearchParams();
// query params

View file

@ -1,6 +1,5 @@
"use client";
import type { FC } from "react";
import { Fragment, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useRouter, useSearchParams } from "next/navigation";
@ -17,7 +16,7 @@ type TIssuePeekOverview = {
handlePeekClose?: () => void;
};
export const IssuePeekOverview: FC<TIssuePeekOverview> = observer((props) => {
export const IssuePeekOverview: React.FC<TIssuePeekOverview> = observer((props) => {
const { anchor, peekId, handlePeekClose } = props;
const router = useRouter();
const searchParams = useSearchParams();

View file

@ -1,16 +1,15 @@
"use client";
import React from "react";
import Image from "next/image";
// images
import Image404 from "@/public/404.svg";
import Image404 from "@/app/assets/404.svg?url";
export const PageNotFound = () => (
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>
<div className="grid h-full place-items-center p-4">
<div className="space-y-8 text-center">
<div className="relative mx-auto h-60 w-60 lg:h-80 lg:w-80">
<Image src={Image404} layout="fill" alt="404- Page not found" />
<Image src={Image404} alt="404- Page not found" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Oops! Something went wrong.</h3>

View file

@ -0,0 +1,140 @@
"use client";
import { useEffect, useRef } from "react";
import { BProgress } from "@bprogress/core";
import { useNavigation } from "react-router";
import "@bprogress/core/css";
/**
* Progress bar configuration options
*/
interface ProgressConfig {
/** Whether to show the loading spinner */
showSpinner: boolean;
/** Minimum progress percentage (0-1) */
minimum: number;
/** Animation speed in milliseconds */
speed: number;
/** Auto-increment speed in milliseconds */
trickleSpeed: number;
/** CSS easing function */
easing: string;
/** Enable auto-increment */
trickle: boolean;
/** Delay before showing progress bar in milliseconds */
delay: number;
/** Whether to disable the progress bar */
isDisabled?: boolean;
}
/**
* Configuration for the progress bar
*/
const PROGRESS_CONFIG: Readonly<ProgressConfig> = {
showSpinner: false,
minimum: 0.1,
speed: 400,
trickleSpeed: 800,
easing: "ease",
trickle: true,
delay: 0,
isDisabled: import.meta.env.PROD, // Disable progress bar in production builds
} as const;
/**
* Navigation Progress Bar Component
*
* Automatically displays a progress bar at the top of the page during React Router navigation.
* Integrates with React Router's useNavigation hook to monitor route changes.
*
* Note: Progress bar is disabled in production builds.
*
* @returns null - This component doesn't render any visible elements
*
* @example
* ```tsx
* function App() {
* return (
* <>
* <AppProgressBar />
* <Outlet />
* </>
* );
* }
* ```
*/
export function AppProgressBar(): null {
const navigation = useNavigation();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startedRef = useRef<boolean>(false);
// Initialize BProgress once on mount
useEffect(() => {
// Skip initialization in production builds
if (PROGRESS_CONFIG.isDisabled) {
return;
}
// Configure BProgress with our settings
BProgress.configure({
showSpinner: PROGRESS_CONFIG.showSpinner,
minimum: PROGRESS_CONFIG.minimum,
speed: PROGRESS_CONFIG.speed,
trickleSpeed: PROGRESS_CONFIG.trickleSpeed,
easing: PROGRESS_CONFIG.easing,
trickle: PROGRESS_CONFIG.trickle,
});
// Render the progress bar element in the DOM
BProgress.render(true);
// Cleanup on unmount
return () => {
if (BProgress.isStarted()) {
BProgress.done();
}
};
}, []);
// Handle navigation state changes
useEffect(() => {
// Skip navigation tracking in production builds
if (PROGRESS_CONFIG.isDisabled) {
return;
}
if (navigation.state === "idle") {
// Navigation complete - clear any pending timer
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
// Complete progress if it was started
if (startedRef.current) {
BProgress.done();
startedRef.current = false;
}
} else {
// Navigation in progress (loading or submitting)
// Only start if not already started and no timer pending
if (timerRef.current === null && !startedRef.current) {
timerRef.current = setTimeout((): void => {
if (!BProgress.isStarted()) {
BProgress.start();
startedRef.current = true;
}
timerRef.current = null;
}, PROGRESS_CONFIG.delay);
}
}
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
};
}, [navigation.state]);
return null;
}

View file

@ -0,0 +1 @@
export * from "./AppProgressBar";

View file

@ -1,25 +1,25 @@
"use client";
import type { ReactNode } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane imports
import { SPACE_BASE_PATH } from "@plane/constants";
// assets
import PlaneBackgroundPatternDark from "@/app/assets/auth/background-pattern-dark.svg?url";
import PlaneBackgroundPattern from "@/app/assets/auth/background-pattern.svg?url";
import BlackHorizontalLogo from "@/app/assets/plane-logos/black-horizontal-with-blue-logo.png?url";
import WhiteHorizontalLogo from "@/app/assets/plane-logos/white-horizontal-with-blue-logo.png?url";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { InstanceFailureView } from "@/components/instance/instance-failure-view";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useUser } from "@/hooks/store/use-user";
// assets
import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg";
import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg";
import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
export const InstanceProvider = observer(({ children }: { children: ReactNode }) => {
export const InstanceProvider = observer(({ children }: { children: React.ReactNode }) => {
const { fetchInstanceInfo, instance, error } = useInstance();
const { fetchCurrentUser } = useUser();
const { resolvedTheme } = useTheme();

View file

@ -1,6 +1,5 @@
"use client";
import type { ReactNode } from "react";
import { createContext } from "react";
// plane web store
import { RootStore } from "@/plane-web/store/root.store";
@ -19,7 +18,7 @@ function initializeStore() {
}
export type StoreProviderProps = {
children: ReactNode;
children: React.ReactNode;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialState?: any;
};

View file

@ -1,12 +1,11 @@
"use client";
import type { ReactNode } from "react";
import { useTheme } from "next-themes";
// plane imports
import { Toast } from "@plane/propel/toast";
import { resolveGeneralTheme } from "@plane/utils";
export const ToastProvider = ({ children }: { children: ReactNode }) => {
export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
// themes
const { resolvedTheme } = useTheme();

View file

@ -1,4 +1,3 @@
import type { ReactNode } from "react";
import Link from "next/link";
// helpers
import { SUPPORT_EMAIL } from "./common.helper";
@ -83,11 +82,11 @@ export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAuthenticationErrorCodes;
title: string;
message: ReactNode;
message: React.ReactNode;
};
const errorCodeMessages: {
[key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
[key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => React.ReactNode };
} = {
// global
[EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED]: {

View file

@ -1,10 +1,4 @@
import { clsx } from "clsx";
import type { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "";
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export const resolveGeneralTheme = (resolvedTheme: string | undefined) =>
resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system";

13
apps/space/middleware.js Normal file
View file

@ -0,0 +1,13 @@
import { next } from "@vercel/edge";
export default function middleware() {
return next({
headers: {
"Referrer-Policy": "origin-when-cross-origin",
"X-Frame-Options": "SAMEORIGIN",
"X-Content-Type-Options": "nosniff",
"X-DNS-Prefetch-Control": "on",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
},
});
}

View file

@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View file

@ -1,43 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
trailingSlash: true,
output: "standalone",
basePath: process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "",
reactStrictMode: false,
swcMinify: true,
async headers() {
return [
{
source: "/",
headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }], // clickjacking protection
},
];
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
],
unoptimized: true,
},
experimental: {
optimizePackageImports: [
"@plane/constants",
"@plane/editor",
"@plane/hooks",
"@plane/i18n",
"@plane/logger",
"@plane/propel",
"@plane/services",
"@plane/shared-state",
"@plane/types",
"@plane/ui",
"@plane/utils",
],
},
};
module.exports = nextConfig;

View file

@ -0,0 +1,30 @@
worker_processes 4;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
set_real_ip_from 0.0.0.0/0;
real_ip_recursive on;
real_ip_header X-Forward-For;
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
access_log /dev/stdout;
error_log /dev/stderr;
server {
listen 3000;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /spaces/index.html;
}
}
}

View file

@ -3,22 +3,24 @@
"version": "1.1.0",
"private": true,
"license": "AGPL-3.0",
"type": "module",
"scripts": {
"dev": "next dev -p 3002",
"build": "next build",
"start": "next start",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist",
"dev": "cross-env NODE_ENV=development PORT=3002 node server.mjs",
"build": "react-router build",
"preview": "react-router build && cross-env NODE_ENV=production PORT=3002 node server.mjs",
"start": "serve -s build/client -l 3002",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf .react-router && rm -rf node_modules && rm -rf dist && rm -rf build",
"check:lint": "eslint . --max-warnings 28",
"check:types": "tsc --noEmit",
"check:types": "react-router typegen && tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\""
},
"dependencies": {
"@bprogress/core": "catalog:",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@headlessui/react": "^1.7.13",
"@mui/material": "^5.14.1",
"@plane/constants": "workspace:*",
"@plane/editor": "workspace:*",
"@plane/i18n": "workspace:*",
@ -28,38 +30,50 @@
"@plane/ui": "workspace:*",
"@plane/utils": "workspace:*",
"@popperjs/core": "^2.11.8",
"@react-router/express": "^7.9.3",
"@react-router/node": "^7.9.3",
"@vercel/edge": "1.2.2",
"axios": "catalog:",
"clsx": "^2.0.0",
"compression": "^1.8.1",
"cross-env": "^7.0.3",
"date-fns": "^4.1.0",
"dotenv": "^16.3.1",
"dotenv": "^16.4.5",
"express": "^5.1.0",
"http-proxy-middleware": "^3.0.5",
"isbot": "^5.1.31",
"lodash-es": "catalog:",
"lowlight": "^2.9.0",
"lucide-react": "catalog:",
"mobx": "catalog:",
"mobx-react": "catalog:",
"mobx-utils": "catalog:",
"next": "catalog:",
"morgan": "^1.10.1",
"next-themes": "^0.2.1",
"nprogress": "^0.2.0",
"react": "catalog:",
"react-dom": "catalog:",
"react-dropzone": "^14.2.3",
"react-hook-form": "7.51.5",
"react-popper": "^2.3.0",
"sharp": "catalog:",
"react-router": "^7.9.1",
"react-router-dom": "^7.9.1",
"serve": "14.2.5",
"swr": "catalog:",
"tailwind-merge": "^2.0.0",
"uuid": "catalog:"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@react-router/dev": "^7.9.1",
"@types/compression": "^1.8.1",
"@types/express": "4.17.23",
"@types/lodash-es": "catalog:",
"@types/morgan": "^1.9.10",
"@types/node": "catalog:",
"@types/nprogress": "^0.2.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vite": "7.1.7",
"vite-tsconfig-paths": "^5.1.4"
}
}

View file

@ -0,0 +1,8 @@
import type { Config } from "@react-router/dev/config";
export default {
appDirectory: "app",
basename: process.env.NEXT_PUBLIC_SPACE_BASE_PATH,
// Space runs as a client-side app; build a static client bundle only
ssr: false,
} satisfies Config;

76
apps/space/server.mjs Normal file
View file

@ -0,0 +1,76 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import compression from "compression";
import dotenv from "dotenv";
import express from "express";
import morgan from "morgan";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, ".env") });
const BUILD_PATH = "./build/server/index.js";
const DEVELOPMENT = process.env.NODE_ENV !== "production";
// Derive the port from NEXT_PUBLIC_SPACE_BASE_URL when available, otherwise
// default to http://localhost:3002 and fall back to PORT env if explicitly set.
const DEFAULT_BASE_URL = "http://localhost:3002";
const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || DEFAULT_BASE_URL;
let parsedBaseUrl;
try {
parsedBaseUrl = new URL(SPACE_BASE_URL);
} catch {
parsedBaseUrl = new URL(DEFAULT_BASE_URL);
}
const PORT = Number.parseInt(parsedBaseUrl.port, 10);
async function start() {
const app = express();
app.use(compression());
app.disable("x-powered-by");
if (DEVELOPMENT) {
console.log("Starting development server");
const vite = await import("vite").then((vite) =>
vite.createServer({
server: { middlewareMode: true },
appType: "custom",
})
);
app.use(vite.middlewares);
app.use(async (req, res, next) => {
try {
const source = await vite.ssrLoadModule("./server/app.ts");
return source.app(req, res, next);
} catch (error) {
if (error instanceof Error) {
vite.ssrFixStacktrace(error);
}
next(error);
}
});
} else {
console.log("Starting production server");
app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" }));
app.use(morgan("tiny"));
app.use(express.static("build/client", { maxAge: "1h" }));
app.use(await import(BUILD_PATH).then((mod) => mod.app));
}
app.listen(PORT, () => {
const origin = `${parsedBaseUrl.protocol}//${parsedBaseUrl.hostname}:${PORT}`;
console.log(`Server is running on ${origin}`);
});
}
start().catch((error) => {
console.error(error);
process.exit(1);
});

46
apps/space/server/app.ts Normal file
View file

@ -0,0 +1,46 @@
import "react-router";
import { createRequestHandler } from "@react-router/express";
import express from "express";
import type { Express } from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
const NEXT_PUBLIC_API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL
? process.env.NEXT_PUBLIC_API_BASE_URL.replace(/\/$/, "")
: "http://127.0.0.1:8000";
const NEXT_PUBLIC_API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH
? process.env.NEXT_PUBLIC_API_BASE_PATH.replace(/\/+$/, "")
: "/api";
const NORMALIZED_API_BASE_PATH = NEXT_PUBLIC_API_BASE_PATH.startsWith("/")
? NEXT_PUBLIC_API_BASE_PATH
: `/${NEXT_PUBLIC_API_BASE_PATH}`;
const NEXT_PUBLIC_SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH
? process.env.NEXT_PUBLIC_SPACE_BASE_PATH.replace(/\/$/, "")
: "/";
export const app: Express = express();
// Ensure proxy-aware hostname/URL handling (e.g., X-Forwarded-Host/Proto)
// so generated URLs/redirects reflect the public host when behind Nginx.
// See related fix in Remix Express adapter.
app.set("trust proxy", true);
app.use(
"/api",
createProxyMiddleware({
target: NEXT_PUBLIC_API_BASE_URL,
changeOrigin: true,
secure: false,
pathRewrite: (path: string) =>
NORMALIZED_API_BASE_PATH === "/api" ? path : path.replace(/^\/api/, NORMALIZED_API_BASE_PATH),
})
);
const router = express.Router();
router.use(
createRequestHandler({
build: () => import("virtual:react-router/server-build"),
})
);
app.use(NEXT_PUBLIC_SPACE_BASE_PATH, router);

View file

@ -495,3 +495,30 @@ body {
.scrollbar-lg::-webkit-scrollbar-thumb {
border: 4px solid rgba(0, 0, 0, 0);
}
/* Progress Bar Styles */
:root {
--bprogress-color: rgb(var(--color-primary-100)) !important;
--bprogress-height: 2.5px !important;
}
.bprogress {
pointer-events: none;
}
.bprogress .bar {
background: linear-gradient(
90deg,
rgba(var(--color-primary-100), 0.8) 0%,
rgba(var(--color-primary-100), 1) 100%
) !important;
will-change: width, opacity;
}
.bprogress .peg {
display: block;
box-shadow:
0 0 8px rgba(var(--color-primary-100), 0.6),
0 0 4px rgba(var(--color-primary-100), 0.4) !important;
will-change: transform, opacity;
}

View file

@ -1,28 +1,18 @@
{
"extends": "@plane/typescript-config/nextjs.json",
"plugins": [
{
"name": "next"
}
],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "additional.d.ts", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"extends": "@plane/typescript-config/react-router.json",
"compilerOptions": {
"baseUrl": ".",
"jsx": "preserve",
"rootDirs": [".", "./.react-router/types"],
"types": ["node", "vite/client"],
"paths": {
"@/app/*": ["app/*"],
"@/*": ["core/*"],
"@/helpers/*": ["helpers/*"],
"@/public/*": ["public/*"],
"@/styles/*": ["styles/*"],
"@/plane-web/*": ["ce/*"]
},
"plugins": [
{
"name": "next"
}
],
"strictNullChecks": true
}
},
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*", "additional.d.ts"],
"exclude": ["node_modules"]
}

59
apps/space/vite.config.ts Normal file
View file

@ -0,0 +1,59 @@
import path from "node:path";
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { joinUrlPath } from "@plane/utils";
const PUBLIC_ENV_KEYS = [
"NEXT_PUBLIC_API_BASE_URL",
"NEXT_PUBLIC_API_BASE_PATH",
"NEXT_PUBLIC_ADMIN_BASE_URL",
"NEXT_PUBLIC_ADMIN_BASE_PATH",
"NEXT_PUBLIC_SPACE_BASE_URL",
"NEXT_PUBLIC_SPACE_BASE_PATH",
"NEXT_PUBLIC_LIVE_BASE_URL",
"NEXT_PUBLIC_LIVE_BASE_PATH",
"NEXT_PUBLIC_WEB_BASE_URL",
"NEXT_PUBLIC_WEB_BASE_PATH",
"NEXT_PUBLIC_WEBSITE_URL",
"NEXT_PUBLIC_SUPPORT_EMAIL",
];
const publicEnv = PUBLIC_ENV_KEYS.reduce<Record<string, string>>((acc, key) => {
acc[key] = process.env[key] ?? "";
return acc;
}, {});
export default defineConfig(({ isSsrBuild }) => {
// Only produce an SSR bundle when explicitly enabled.
// For static deployments (default), we skip the server build entirely.
const enableSsrBuild = process.env.SPACE_ENABLE_SSR_BUILD === "true";
const basePath = joinUrlPath(process.env.NEXT_PUBLIC_SPACE_BASE_PATH ?? "", "/") ?? "/";
return {
base: basePath,
define: {
"process.env": JSON.stringify(publicEnv),
},
build: {
assetsInlineLimit: 0,
rollupOptions:
isSsrBuild && enableSsrBuild
? {
input: path.resolve(__dirname, "server/app.ts"),
}
: undefined,
},
plugins: [reactRouter(), tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] })],
resolve: {
alias: {
// Next.js compatibility shims used within space
"next/image": path.resolve(__dirname, "app/compat/next/image.tsx"),
"next/link": path.resolve(__dirname, "app/compat/next/link.tsx"),
"next/navigation": path.resolve(__dirname, "app/compat/next/navigation.ts"),
},
dedupe: ["react", "react-dom"],
},
// No SSR-specific overrides needed; alias resolves to ESM build
};
});