diff --git a/.dockerignore b/.dockerignore index fe11e95b68..e0b681fef6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -66,4 +66,4 @@ temp/ .react-router/ build/ node_modules/ -README.md \ No newline at end of file +README.md diff --git a/.gitignore b/.gitignore index 0edc47dccb..f19497acce 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,12 @@ dev-editor storybook-static CLAUDE.md + +build/ +.react-router/ +AGENTS.md + +build/ +.react-router/ +AGENTS.md +temp/ diff --git a/apps/admin/.dockerignore b/apps/admin/.dockerignore new file mode 100644 index 0000000000..92963a35b0 --- /dev/null +++ b/apps/admin/.dockerignore @@ -0,0 +1,5 @@ +# React Router - https://github.com/remix-run/react-router-templates/blob/dc79b1a065f59f3bfd840d4ef75cc27689b611e6/default/.dockerignore +.react-router/ +build/ +node_modules/ +README.md diff --git a/apps/admin/.eslintrc.js b/apps/admin/.eslintrc.cjs similarity index 90% rename from apps/admin/.eslintrc.js rename to apps/admin/.eslintrc.cjs index a0bc76d5d9..d6a1fc8336 100644 --- a/apps/admin/.eslintrc.js +++ b/apps/admin/.eslintrc.cjs @@ -1,6 +1,7 @@ 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 }], diff --git a/apps/admin/Dockerfile.admin b/apps/admin/Dockerfile.admin index 6bfa0765f6..6af4ff6c3b 100644 --- a/apps/admin/Dockerfile.admin +++ b/apps/admin/Dockerfile.admin @@ -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 admin app RUN turbo prune --scope=admin --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 admin package RUN pnpm turbo run build --filter=admin -# ***************************************************************************** -# 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/admin/.next/standalone ./ -COPY --from=installer /app/apps/admin/.next/static ./apps/admin/.next/static -COPY --from=installer /app/apps/admin/public ./apps/admin/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/admin/nginx/nginx.conf /etc/nginx/nginx.conf +COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/god-mode EXPOSE 3000 -CMD ["node", "apps/admin/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;"] \ No newline at end of file diff --git a/apps/admin/app/(all)/(dashboard)/ai/form.tsx b/apps/admin/app/(all)/(dashboard)/ai/form.tsx index 64970a547a..5b931e9d78 100644 --- a/apps/admin/app/(all)/(dashboard)/ai/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/ai/form.tsx @@ -1,5 +1,5 @@ "use client"; -import type { FC } from "react"; + import { useForm } from "react-hook-form"; import { Lightbulb } from "lucide-react"; import { Button } from "@plane/propel/button"; @@ -17,7 +17,7 @@ type IInstanceAIForm = { type AIFormValues = Record; -export const InstanceAIForm: FC = (props) => { +export const InstanceAIForm: React.FC = (props) => { const { config } = props; // store const { updateInstanceConfigurations } = useInstance(); diff --git a/apps/admin/app/(all)/(dashboard)/ai/layout.tsx b/apps/admin/app/(all)/(dashboard)/ai/layout.tsx deleted file mode 100644 index 303ed56045..0000000000 --- a/apps/admin/app/(all)/(dashboard)/ai/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { ReactNode } from "react"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Artificial Intelligence Settings - God Mode", -}; - -export default function AILayout({ children }: { children: ReactNode }) { - return <>{children}; -} diff --git a/apps/admin/app/(all)/(dashboard)/ai/page.tsx b/apps/admin/app/(all)/(dashboard)/ai/page.tsx index 2a0747776e..e5323e92b8 100644 --- a/apps/admin/app/(all)/(dashboard)/ai/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/ai/page.tsx @@ -6,9 +6,10 @@ import { Loader } from "@plane/ui"; // hooks import { useInstance } from "@/hooks/store"; // components +import type { Route } from "./+types/page"; import { InstanceAIForm } from "./form"; -const InstanceAIPage = observer(() => { +const InstanceAIPage = observer>(() => { // store const { fetchInstanceConfigurations, formattedConfig } = useInstance(); @@ -42,4 +43,6 @@ const InstanceAIPage = observer(() => { ); }); +export const meta: Route.MetaFunction = () => [{ title: "Artificial Intelligence Settings - God Mode" }]; + export default InstanceAIPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx new file mode 100644 index 0000000000..fefc1ac892 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx @@ -0,0 +1,210 @@ +"use client"; + +import type { FC } from "react"; +import { useState } from "react"; +import { isEmpty } from "lodash-es"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceGiteaAuthenticationConfigurationKeys } from "@plane/types"; +import { Button, getButtonStyling } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { CodeBlock } from "@/components/common/code-block"; +import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import { ControllerInput } from "@/components/common/controller-input"; +import type { TCopyField } from "@/components/common/copy-field"; +import { CopyField } from "@/components/common/copy-field"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + config: IFormattedInstanceConfiguration; +}; + +type GiteaConfigFormValues = Record; + +export const InstanceGiteaConfigForm: FC = (props) => { + const { config } = props; + // states + const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + GITEA_HOST: config["GITEA_HOST"] || "https://gitea.com", + GITEA_CLIENT_ID: config["GITEA_CLIENT_ID"], + GITEA_CLIENT_SECRET: config["GITEA_CLIENT_SECRET"], + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const GITEA_FORM_FIELDS: TControllerInputFormField[] = [ + { + key: "GITEA_HOST", + type: "text", + label: "Gitea Host", + description: ( + <>Use the URL of your Gitea instance. For the official Gitea instance, use "https://gitea.com". + ), + placeholder: "https://gitea.com", + error: Boolean(errors.GITEA_HOST), + required: true, + }, + { + key: "GITEA_CLIENT_ID", + type: "text", + label: "Client ID", + description: ( + <> + You will get this from your{" "} + + Gitea OAuth application settings. + + + ), + placeholder: "70a44354520df8bd9bcd", + error: Boolean(errors.GITEA_CLIENT_ID), + required: true, + }, + { + key: "GITEA_CLIENT_SECRET", + type: "password", + label: "Client secret", + description: ( + <> + Your client secret is also found in your{" "} + + Gitea OAuth application settings. + + + ), + placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb", + error: Boolean(errors.GITEA_CLIENT_SECRET), + required: true, + }, + ]; + + const GITEA_SERVICE_FIELD: TCopyField[] = [ + { + key: "Callback_URI", + label: "Callback URI", + url: `${originURL}/auth/gitea/callback/`, + description: ( + <> + We will auto-generate this. Paste this into your Authorized Callback URI{" "} + field{" "} + + here. + + + ), + }, + ]; + + const onSubmit = async (formData: GiteaConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then((response = []) => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your Gitea authentication is configured. You should test it now.", + }); + reset({ + GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value, + GITEA_CLIENT_ID: response.find((item) => item.key === "GITEA_CLIENT_ID")?.value, + GITEA_CLIENT_SECRET: response.find((item) => item.key === "GITEA_CLIENT_SECRET")?.value, + }); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
Gitea-provided details for Plane
+ {GITEA_FORM_FIELDS.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Plane-provided details for Gitea
+ {GITEA_SERVICE_FIELD.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx new file mode 100644 index 0000000000..044b428663 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import useSWR from "swr"; +// plane internal packages +import { setPromiseToast } from "@plane/propel/toast"; +import { Loader, ToggleSwitch } from "@plane/ui"; +// components +import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +// hooks +import { useInstance } from "@/hooks/store"; +//local components +import type { Route } from "./+types/page"; +import { InstanceGiteaConfigForm } from "./form"; + +const InstanceGiteaAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // config + const enableGiteaConfig = formattedConfig?.IS_GITEA_ENABLED ?? ""; + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_GITEA_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration...", + success: { + title: "Configuration saved", + message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`, + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + + const isGiteaEnabled = enableGiteaConfig === "1"; + + return ( + <> +
+
+ } + config={ + { + updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); +export const meta: Route.MetaFunction = () => [{ title: "Gitea Authentication - God Mode" }]; + +export default InstanceGiteaAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx index ae0f54c4f0..86ab729b8a 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx @@ -1,6 +1,5 @@ "use client"; -import type { FC } from "react"; import { useState } from "react"; import { isEmpty } from "lodash-es"; import Link from "next/link"; @@ -29,7 +28,7 @@ type Props = { type GithubConfigFormValues = Record; -export const InstanceGithubConfigForm: FC = (props) => { +export const InstanceGithubConfigForm: React.FC = (props) => { const { config } = props; // states const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx deleted file mode 100644 index 2da5a9031e..0000000000 --- a/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { ReactNode } from "react"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "GitHub Authentication - God Mode", -}; - -export default function GitHubAuthenticationLayout({ children }: { children: ReactNode }) { - return <>{children}; -} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx index 5709ba4ba4..3d488268fc 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx @@ -10,16 +10,17 @@ import { setPromiseToast } from "@plane/propel/toast"; import { Loader, ToggleSwitch } from "@plane/ui"; import { resolveGeneralTheme } from "@plane/utils"; // components +import githubLightModeImage from "@/app/assets/logos/github-black.png?url"; +import githubDarkModeImage from "@/app/assets/logos/github-white.png?url"; import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; // hooks import { useInstance } from "@/hooks/store"; // icons -import githubLightModeImage from "@/public/logos/github-black.png"; -import githubDarkModeImage from "@/public/logos/github-white.png"; // local components +import type { Route } from "./+types/page"; import { InstanceGithubConfigForm } from "./form"; -const InstanceGithubAuthenticationPage = observer(() => { +const InstanceGithubAuthenticationPage = observer>(() => { // store const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); // state @@ -44,7 +45,7 @@ const InstanceGithubAuthenticationPage = observer(() => { loading: "Saving Configuration...", success: { title: "Configuration saved", - message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`, + message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`, }, error: { title: "Error", @@ -111,4 +112,6 @@ const InstanceGithubAuthenticationPage = observer(() => { ); }); +export const meta: Route.MetaFunction = () => [{ title: "GitHub Authentication - God Mode" }]; + export default InstanceGithubAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx index 91e4ee8eca..f8ffc6c35e 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx @@ -1,4 +1,3 @@ -import type { FC } from "react"; import { useState } from "react"; import { isEmpty } from "lodash-es"; import Link from "next/link"; @@ -25,7 +24,7 @@ type Props = { type GitlabConfigFormValues = Record; -export const InstanceGitlabConfigForm: FC = (props) => { +export const InstanceGitlabConfigForm: React.FC = (props) => { const { config } = props; // states const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx deleted file mode 100644 index 79b5de5afe..0000000000 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { ReactNode } from "react"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "GitLab Authentication - God Mode", -}; - -export default function GitlabAuthenticationLayout({ children }: { children: ReactNode }) { - return <>{children}; -} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx index ae85168aeb..6b03f5e2ff 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx @@ -7,15 +7,16 @@ import useSWR from "swr"; import { setPromiseToast } from "@plane/propel/toast"; import { Loader, ToggleSwitch } from "@plane/ui"; // components +import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; // hooks import { useInstance } from "@/hooks/store"; // icons -import GitlabLogo from "@/public/logos/gitlab-logo.svg"; // local components +import type { Route } from "./+types/page"; import { InstanceGitlabConfigForm } from "./form"; -const InstanceGitlabAuthenticationPage = observer(() => { +const InstanceGitlabAuthenticationPage = observer>(() => { // store const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); // state @@ -38,7 +39,7 @@ const InstanceGitlabAuthenticationPage = observer(() => { loading: "Saving Configuration...", success: { title: "Configuration saved", - message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`, + message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`, }, error: { title: "Error", @@ -99,4 +100,6 @@ const InstanceGitlabAuthenticationPage = observer(() => { ); }); +export const meta: Route.MetaFunction = () => [{ title: "GitLab Authentication - God Mode" }]; + export default InstanceGitlabAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx index d9c3646b73..f3235f6b70 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx @@ -1,5 +1,5 @@ "use client"; -import type { FC } from "react"; + import { useState } from "react"; import { isEmpty } from "lodash-es"; import Link from "next/link"; @@ -27,7 +27,7 @@ type Props = { type GoogleConfigFormValues = Record; -export const InstanceGoogleConfigForm: FC = (props) => { +export const InstanceGoogleConfigForm: React.FC = (props) => { const { config } = props; // states const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx deleted file mode 100644 index ddc0cff458..0000000000 --- a/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { ReactNode } from "react"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Google Authentication - God Mode", -}; - -export default function GoogleAuthenticationLayout({ children }: { children: ReactNode }) { - return <>{children}; -} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx index d6ca370d49..b18a4b1c1d 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx @@ -7,15 +7,16 @@ import useSWR from "swr"; import { setPromiseToast } from "@plane/propel/toast"; import { Loader, ToggleSwitch } from "@plane/ui"; // components +import GoogleLogo from "@/app/assets/logos/google-logo.svg?url"; import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; // hooks import { useInstance } from "@/hooks/store"; // icons -import GoogleLogo from "@/public/logos/google-logo.svg"; // local components +import type { Route } from "./+types/page"; import { InstanceGoogleConfigForm } from "./form"; -const InstanceGoogleAuthenticationPage = observer(() => { +const InstanceGoogleAuthenticationPage = observer>(() => { // store const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); // state @@ -38,7 +39,7 @@ const InstanceGoogleAuthenticationPage = observer(() => { loading: "Saving Configuration...", success: { title: "Configuration saved", - message: () => `Google authentication is now ${value ? "active" : "disabled"}.`, + message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`, }, error: { title: "Error", @@ -100,4 +101,6 @@ const InstanceGoogleAuthenticationPage = observer(() => { ); }); +export const meta: Route.MetaFunction = () => [{ title: "Google Authentication - God Mode" }]; + export default InstanceGoogleAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx deleted file mode 100644 index bed80f2240..0000000000 --- a/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { ReactNode } from "react"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Authentication Settings - Plane Web", -}; - -export default function AuthenticationLayout({ children }: { children: ReactNode }) { - return <>{children}; -} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx index 16be71e586..380966e024 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx @@ -12,8 +12,9 @@ import { cn } from "@plane/utils"; import { useInstance } from "@/hooks/store"; // plane admin components import { AuthenticationModes } from "@/plane-admin/components/authentication"; +import type { Route } from "./+types/page"; -const InstanceAuthenticationPage = observer(() => { +const InstanceAuthenticationPage = observer>(() => { // store const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); @@ -111,4 +112,6 @@ const InstanceAuthenticationPage = observer(() => { ); }); +export const meta: Route.MetaFunction = () => [{ title: "Authentication Settings - Plane Web" }]; + export default InstanceAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx b/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx index 450a5f4e93..2c00311b79 100644 --- a/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx +++ b/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx @@ -1,7 +1,6 @@ "use client"; -import type { FC } from "react"; -import React, { useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; // types import { Button } from "@plane/propel/button"; @@ -31,7 +30,7 @@ const EMAIL_SECURITY_OPTIONS: { [key in TEmailSecurityKeys]: string } = { NONE: "No email security", }; -export const InstanceEmailForm: FC = (props) => { +export const InstanceEmailForm: React.FC = (props) => { const { config } = props; // states const [isSendTestEmailModalOpen, setIsSendTestEmailModalOpen] = useState(false); diff --git a/apps/admin/app/(all)/(dashboard)/email/layout.tsx b/apps/admin/app/(all)/(dashboard)/email/layout.tsx deleted file mode 100644 index 0e6fc06cd3..0000000000 --- a/apps/admin/app/(all)/(dashboard)/email/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { ReactNode } from "react"; -import type { Metadata } from "next"; - -interface EmailLayoutProps { - children: ReactNode; -} - -export const metadata: Metadata = { - title: "Email Settings - God Mode", -}; - -export default function EmailLayout({ children }: EmailLayoutProps) { - return <>{children}; -} diff --git a/apps/admin/app/(all)/(dashboard)/email/page.tsx b/apps/admin/app/(all)/(dashboard)/email/page.tsx index a509f6d28e..152c51dda8 100644 --- a/apps/admin/app/(all)/(dashboard)/email/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/email/page.tsx @@ -8,9 +8,10 @@ import { Loader, ToggleSwitch } from "@plane/ui"; // hooks import { useInstance } from "@/hooks/store"; // components +import type { Route } from "./+types/page"; import { InstanceEmailForm } from "./email-config-form"; -const InstanceEmailPage: React.FC = observer(() => { +const InstanceEmailPage = observer>(() => { // store const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance(); @@ -91,4 +92,6 @@ const InstanceEmailPage: React.FC = observer(() => { ); }); +export const meta: Route.MetaFunction = () => [{ title: "Email Settings - God Mode" }]; + export default InstanceEmailPage; diff --git a/apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx b/apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx index 0911709607..89043fcc18 100644 --- a/apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx +++ b/apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx @@ -1,5 +1,4 @@ -import type { FC } from "react"; -import React, { useEffect, useState } from "react"; +import { useEffect, useState, Fragment } from "react"; import { Dialog, Transition } from "@headlessui/react"; // plane imports import { Button } from "@plane/propel/button"; @@ -20,7 +19,7 @@ enum ESendEmailSteps { const instanceService = new InstanceService(); -export const SendTestEmailModal: FC = (props) => { +export const SendTestEmailModal: React.FC = (props) => { const { isOpen, handleClose } = props; // state @@ -62,10 +61,10 @@ export const SendTestEmailModal: FC = (props) => { }; return ( - + = (props) => {
= observer((props) => { +export const GeneralConfigurationForm: React.FC = observer((props) => { const { instance, instanceAdmins } = props; // hooks const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance(); diff --git a/apps/admin/app/(all)/(dashboard)/general/intercom.tsx b/apps/admin/app/(all)/(dashboard)/general/intercom.tsx index a6f17d629a..61a417c45b 100644 --- a/apps/admin/app/(all)/(dashboard)/general/intercom.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/intercom.tsx @@ -1,6 +1,5 @@ "use client"; -import type { FC } from "react"; import { useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; @@ -14,7 +13,7 @@ type TIntercomConfig = { isTelemetryEnabled: boolean; }; -export const IntercomConfig: FC = observer((props) => { +export const IntercomConfig: React.FC = observer((props) => { const { isTelemetryEnabled } = props; // hooks const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance(); diff --git a/apps/admin/app/(all)/(dashboard)/general/layout.tsx b/apps/admin/app/(all)/(dashboard)/general/layout.tsx deleted file mode 100644 index f5167e7504..0000000000 --- a/apps/admin/app/(all)/(dashboard)/general/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { ReactNode } from "react"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "General Settings - God Mode", -}; - -export default function GeneralLayout({ children }: { children: ReactNode }) { - return <>{children}; -} diff --git a/apps/admin/app/(all)/(dashboard)/general/page.tsx b/apps/admin/app/(all)/(dashboard)/general/page.tsx index f0d32f2618..4531da1e20 100644 --- a/apps/admin/app/(all)/(dashboard)/general/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/page.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react"; // hooks import { useInstance } from "@/hooks/store"; // components +import type { Route } from "./+types/page"; import { GeneralConfigurationForm } from "./form"; function GeneralPage() { @@ -28,4 +29,6 @@ function GeneralPage() { ); } +export const meta: Route.MetaFunction = () => [{ title: "General Settings - God Mode" }]; + export default observer(GeneralPage); diff --git a/apps/admin/app/(all)/(dashboard)/header.tsx b/apps/admin/app/(all)/(dashboard)/header.tsx index 82d7241f61..049c308342 100644 --- a/apps/admin/app/(all)/(dashboard)/header.tsx +++ b/apps/admin/app/(all)/(dashboard)/header.tsx @@ -1,6 +1,5 @@ "use client"; -import type { FC } from "react"; import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; import { Menu, Settings } from "lucide-react"; @@ -11,7 +10,7 @@ import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; // hooks import { useTheme } from "@/hooks/store"; -export const HamburgerToggle: FC = observer(() => { +export const HamburgerToggle = observer(() => { const { isSidebarCollapsed, toggleSidebar } = useTheme(); return (
{ ); }); -export const AdminHeader: FC = observer(() => { +export const AdminHeader = observer(() => { const pathName = usePathname(); const getHeaderTitle = (pathName: string) => { @@ -44,6 +43,8 @@ export const AdminHeader: FC = observer(() => { return "GitHub"; case "gitlab": return "GitLab"; + case "gitea": + return "Gitea"; case "workspace": return "Workspace"; case "create": diff --git a/apps/admin/app/(all)/(dashboard)/image/form.tsx b/apps/admin/app/(all)/(dashboard)/image/form.tsx index f6adcaee4b..da105bb3e6 100644 --- a/apps/admin/app/(all)/(dashboard)/image/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/image/form.tsx @@ -1,5 +1,4 @@ "use client"; -import type { FC } from "react"; import { useForm } from "react-hook-form"; import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; @@ -15,7 +14,7 @@ type IInstanceImageConfigForm = { type ImageConfigFormValues = Record; -export const InstanceImageConfigForm: FC = (props) => { +export const InstanceImageConfigForm: React.FC = (props) => { const { config } = props; // store hooks const { updateInstanceConfigurations } = useInstance(); diff --git a/apps/admin/app/(all)/(dashboard)/image/layout.tsx b/apps/admin/app/(all)/(dashboard)/image/layout.tsx deleted file mode 100644 index 559a15f9d4..0000000000 --- a/apps/admin/app/(all)/(dashboard)/image/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { ReactNode } from "react"; -import type { Metadata } from "next"; - -interface ImageLayoutProps { - children: ReactNode; -} - -export const metadata: Metadata = { - title: "Images Settings - God Mode", -}; - -export default function ImageLayout({ children }: ImageLayoutProps) { - return <>{children}; -} diff --git a/apps/admin/app/(all)/(dashboard)/image/page.tsx b/apps/admin/app/(all)/(dashboard)/image/page.tsx index ade9687d5e..8a8fad01ad 100644 --- a/apps/admin/app/(all)/(dashboard)/image/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/image/page.tsx @@ -6,9 +6,10 @@ import { Loader } from "@plane/ui"; // hooks import { useInstance } from "@/hooks/store"; // local +import type { Route } from "./+types/page"; import { InstanceImageConfigForm } from "./form"; -const InstanceImagePage = observer(() => { +const InstanceImagePage = observer>(() => { // store const { formattedConfig, fetchInstanceConfigurations } = useInstance(); @@ -38,4 +39,6 @@ const InstanceImagePage = observer(() => { ); }); +export const meta: Route.MetaFunction = () => [{ title: "Images Settings - God Mode" }]; + export default InstanceImagePage; diff --git a/apps/admin/app/(all)/(dashboard)/layout.tsx b/apps/admin/app/(all)/(dashboard)/layout.tsx index 76d74f4638..da9be8bb91 100644 --- a/apps/admin/app/(all)/(dashboard)/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/layout.tsx @@ -1,34 +1,28 @@ "use client"; -import type { FC, ReactNode } from "react"; import { useEffect } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/navigation"; +import { Outlet } from "react-router"; // components import { LogoSpinner } from "@/components/common/logo-spinner"; import { NewUserPopup } from "@/components/new-user-popup"; // hooks import { useUser } from "@/hooks/store"; // local components +import type { Route } from "./+types/layout"; import { AdminHeader } from "./header"; import { AdminSidebar } from "./sidebar"; -type TAdminLayout = { - children: ReactNode; -}; - -const AdminLayout: FC = (props) => { - const { children } = props; +const AdminLayout: React.FC = () => { // router - const router = useRouter(); + const { replace } = useRouter(); // store hooks const { isUserLoggedIn } = useUser(); useEffect(() => { - if (isUserLoggedIn === false) { - router.push("/"); - } - }, [router, isUserLoggedIn]); + if (isUserLoggedIn === false) replace("/"); + }, [replace, isUserLoggedIn]); if (isUserLoggedIn === undefined) { return ( @@ -44,7 +38,9 @@ const AdminLayout: FC = (props) => {
-
{children}
+
+ +
diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx index cf47911901..157c3240b3 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx @@ -1,6 +1,5 @@ "use client"; -import type { FC } from "react"; import { useState, useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; @@ -35,7 +34,7 @@ const helpOptions = [ }, ]; -export const AdminSidebarHelpSection: FC = observer(() => { +export const AdminSidebarHelpSection: React.FC = observer(() => { // states const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); // store diff --git a/apps/admin/app/(all)/(dashboard)/sidebar.tsx b/apps/admin/app/(all)/(dashboard)/sidebar.tsx index e37d6eb5c6..fb8091df03 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar.tsx @@ -1,6 +1,5 @@ "use client"; -import type { FC } from "react"; import { useEffect, useRef } from "react"; import { observer } from "mobx-react"; // plane helpers @@ -12,7 +11,7 @@ import { AdminSidebarDropdown } from "./sidebar-dropdown"; import { AdminSidebarHelpSection } from "./sidebar-help-section"; import { AdminSidebarMenu } from "./sidebar-menu"; -export const AdminSidebar: FC = observer(() => { +export const AdminSidebar = observer(() => { // store const { isSidebarCollapsed, toggleSidebar } = useTheme(); diff --git a/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx b/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx index 0186286a7b..6d69d313f1 100644 --- a/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx @@ -2,9 +2,10 @@ import { observer } from "mobx-react"; // components +import type { Route } from "./+types/page"; import { WorkspaceCreateForm } from "./form"; -const WorkspaceCreatePage = observer(() => ( +const WorkspaceCreatePage = observer>(() => (
Create a new workspace on this instance.
@@ -18,4 +19,6 @@ const WorkspaceCreatePage = observer(() => (
)); +export const meta: Route.MetaFunction = () => [{ title: "Create Workspace - God Mode" }]; + export default WorkspaceCreatePage; diff --git a/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx b/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx deleted file mode 100644 index 4749e2f7b0..0000000000 --- a/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { ReactNode } from "react"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Workspace Management - God Mode", -}; - -export default function WorkspaceManagementLayout({ children }: { children: ReactNode }) { - return <>{children}; -} diff --git a/apps/admin/app/(all)/(dashboard)/workspace/page.tsx b/apps/admin/app/(all)/(dashboard)/workspace/page.tsx index a03c443d8a..5b6c96daaa 100644 --- a/apps/admin/app/(all)/(dashboard)/workspace/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/workspace/page.tsx @@ -16,8 +16,9 @@ import { cn } from "@plane/utils"; import { WorkspaceListItem } from "@/components/workspace/list-item"; // hooks import { useInstance, useWorkspace } from "@/hooks/store"; +import type { Route } from "./+types/page"; -const WorkspaceManagementPage = observer(() => { +const WorkspaceManagementPage = observer>(() => { // states const [isSubmitting, setIsSubmitting] = useState(false); // store @@ -167,4 +168,6 @@ const WorkspaceManagementPage = observer(() => { ); }); +export const meta: Route.MetaFunction = () => [{ title: "Workspace Management - God Mode" }]; + export default WorkspaceManagementPage; diff --git a/apps/admin/app/(all)/(home)/auth-banner.tsx b/apps/admin/app/(all)/(home)/auth-banner.tsx index c0a9a0e926..2af42ed8e4 100644 --- a/apps/admin/app/(all)/(home)/auth-banner.tsx +++ b/apps/admin/app/(all)/(home)/auth-banner.tsx @@ -1,14 +1,15 @@ -import type { FC } from "react"; -import { Info, X } from "lucide-react"; +import { Info } from "lucide-react"; // plane constants import type { TAdminAuthErrorInfo } from "@plane/constants"; +// icons +import { CloseIcon } from "@plane/propel/icons"; type TAuthBanner = { bannerData: TAdminAuthErrorInfo | undefined; handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void; }; -export const AuthBanner: FC = (props) => { +export const AuthBanner: React.FC = (props) => { const { bannerData, handleBannerData } = props; if (!bannerData) return <>; @@ -22,7 +23,7 @@ export const AuthBanner: FC = (props) => { className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-custom-primary-100/20 text-custom-primary-100/80" onClick={() => handleBannerData && handleBannerData(undefined)} > - +
); diff --git a/apps/admin/app/(all)/(home)/auth-helpers.tsx b/apps/admin/app/(all)/(home)/auth-helpers.tsx index 4da6d7eca0..a1271d186f 100644 --- a/apps/admin/app/(all)/(home)/auth-helpers.tsx +++ b/apps/admin/app/(all)/(home)/auth-helpers.tsx @@ -1,4 +1,3 @@ -import type { ReactNode } from "react"; import Image from "next/image"; import Link from "next/link"; import { KeyRound, Mails } from "lucide-react"; @@ -8,16 +7,16 @@ import { SUPPORT_EMAIL, EAdminAuthErrorCodes } from "@plane/constants"; import type { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types"; import { resolveGeneralTheme } from "@plane/utils"; // components +import githubLightModeImage from "@/app/assets/logos/github-black.png?url"; +import githubDarkModeImage from "@/app/assets/logos/github-white.png?url"; +import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; +import GoogleLogo from "@/app/assets/logos/google-logo.svg?url"; import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; import { GithubConfiguration } from "@/components/authentication/github-config"; import { GitlabConfiguration } from "@/components/authentication/gitlab-config"; import { GoogleConfiguration } from "@/components/authentication/google-config"; import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch"; // images -import githubLightModeImage from "@/public/logos/github-black.png"; -import githubDarkModeImage from "@/public/logos/github-white.png"; -import GitlabLogo from "@/public/logos/gitlab-logo.svg"; -import GoogleLogo from "@/public/logos/google-logo.svg"; export enum EErrorAlertType { BANNER_ALERT = "BANNER_ALERT", @@ -28,7 +27,7 @@ export enum EErrorAlertType { } const errorCodeMessages: { - [key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode }; + [key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => React.ReactNode }; } = { // admin [EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: { diff --git a/apps/admin/app/(all)/(home)/layout.tsx b/apps/admin/app/(all)/(home)/layout.tsx index 25638c6776..97626e7e82 100644 --- a/apps/admin/app/(all)/(home)/layout.tsx +++ b/apps/admin/app/(all)/(home)/layout.tsx @@ -1,9 +1,27 @@ "use client"; -export default function RootLayout({ children }: { children: React.ReactNode }) { +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +import { Outlet } from "react-router"; +// hooks +import { useUser } from "@/hooks/store/use-user"; + +function RootLayout() { + // router + const { replace } = useRouter(); + // store hooks + const { isUserLoggedIn } = useUser(); + + useEffect(() => { + if (isUserLoggedIn === true) replace("/general"); + }, [replace, isUserLoggedIn]); + return (
- {children} +
); } + +export default observer(RootLayout); diff --git a/apps/admin/app/(all)/(home)/page.tsx b/apps/admin/app/(all)/(home)/page.tsx index e6ebdf4559..2308c691d1 100644 --- a/apps/admin/app/(all)/(home)/page.tsx +++ b/apps/admin/app/(all)/(home)/page.tsx @@ -8,6 +8,7 @@ import { InstanceSetupForm } from "@/components/instance/setup-form"; // hooks import { useInstance } from "@/hooks/store"; // components +import type { Route } from "./+types/page"; import { InstanceSignInForm } from "./sign-in-form"; const HomePage = () => { @@ -38,3 +39,8 @@ const HomePage = () => { }; export default observer(HomePage); + +export const meta: Route.MetaFunction = () => [ + { title: "Admin – Instance Setup & Sign-In" }, + { name: "description", content: "Configure your Plane instance or sign in to the admin portal." }, +]; diff --git a/apps/admin/app/(all)/(home)/sign-in-form.tsx b/apps/admin/app/(all)/(home)/sign-in-form.tsx index 2049bda615..56f0ca7351 100644 --- a/apps/admin/app/(all)/(home)/sign-in-form.tsx +++ b/apps/admin/app/(all)/(home)/sign-in-form.tsx @@ -1,6 +1,5 @@ "use client"; -import type { FC } from "react"; import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Eye, EyeOff } from "lucide-react"; @@ -46,7 +45,7 @@ const defaultFromData: TFormData = { password: "", }; -export const InstanceSignInForm: FC = () => { +export const InstanceSignInForm: React.FC = () => { // search params const searchParams = useSearchParams(); const emailParam = searchParams.get("email") || undefined; diff --git a/apps/admin/app/(all)/instance.provider.tsx b/apps/admin/app/(all)/instance.provider.tsx index 19e15ec529..8a5cadcd12 100644 --- a/apps/admin/app/(all)/instance.provider.tsx +++ b/apps/admin/app/(all)/instance.provider.tsx @@ -1,14 +1,9 @@ -import type { FC, ReactNode } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; // hooks import { useInstance } from "@/hooks/store"; -type InstanceProviderProps = { - children: ReactNode; -}; - -export const InstanceProvider: FC = observer((props) => { +export const InstanceProvider = observer>((props) => { const { children } = props; // store hooks const { fetchInstanceInfo } = useInstance(); diff --git a/apps/admin/app/(all)/store.provider.tsx b/apps/admin/app/(all)/store.provider.tsx index 648a37119b..35c6cbc018 100644 --- a/apps/admin/app/(all)/store.provider.tsx +++ b/apps/admin/app/(all)/store.provider.tsx @@ -1,6 +1,5 @@ "use client"; -import type { ReactNode } from "react"; import { createContext } from "react"; // plane admin store import { RootStore } from "@/plane-admin/store/root.store"; @@ -24,7 +23,7 @@ function initializeStore(initialData = {}) { } export type StoreProviderProps = { - children: ReactNode; + children: React.ReactNode; // eslint-disable-next-line @typescript-eslint/no-explicit-any initialState?: any; }; diff --git a/apps/admin/app/(all)/user.provider.tsx b/apps/admin/app/(all)/user.provider.tsx index e026c31da3..5ff9e88c7e 100644 --- a/apps/admin/app/(all)/user.provider.tsx +++ b/apps/admin/app/(all)/user.provider.tsx @@ -1,17 +1,12 @@ "use client"; -import type { FC, ReactNode } from "react"; import { useEffect } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; // hooks import { useInstance, useTheme, useUser } from "@/hooks/store"; -interface IUserProvider { - children: ReactNode; -} - -export const UserProvider: FC = observer(({ children }) => { +export const UserProvider = observer>(({ children }) => { // hooks const { isSidebarCollapsed, toggleSidebar } = useTheme(); const { currentUser, fetchCurrentUser } = useUser(); diff --git a/apps/admin/public/favicon/apple-touch-icon.png b/apps/admin/app/assets/favicon/apple-touch-icon.png similarity index 100% rename from apps/admin/public/favicon/apple-touch-icon.png rename to apps/admin/app/assets/favicon/apple-touch-icon.png diff --git a/apps/admin/public/favicon/favicon-16x16.png b/apps/admin/app/assets/favicon/favicon-16x16.png similarity index 100% rename from apps/admin/public/favicon/favicon-16x16.png rename to apps/admin/app/assets/favicon/favicon-16x16.png diff --git a/apps/admin/public/favicon/favicon-32x32.png b/apps/admin/app/assets/favicon/favicon-32x32.png similarity index 100% rename from apps/admin/public/favicon/favicon-32x32.png rename to apps/admin/app/assets/favicon/favicon-32x32.png diff --git a/apps/admin/public/favicon/favicon.ico b/apps/admin/app/assets/favicon/favicon.ico similarity index 100% rename from apps/admin/public/favicon/favicon.ico rename to apps/admin/app/assets/favicon/favicon.ico diff --git a/apps/space/public/404.svg b/apps/admin/app/assets/images/404.svg similarity index 100% rename from apps/space/public/404.svg rename to apps/admin/app/assets/images/404.svg diff --git a/apps/admin/public/images/logo-spinner-dark.gif b/apps/admin/app/assets/images/logo-spinner-dark.gif similarity index 100% rename from apps/admin/public/images/logo-spinner-dark.gif rename to apps/admin/app/assets/images/logo-spinner-dark.gif diff --git a/apps/admin/public/images/logo-spinner-light.gif b/apps/admin/app/assets/images/logo-spinner-light.gif similarity index 100% rename from apps/admin/public/images/logo-spinner-light.gif rename to apps/admin/app/assets/images/logo-spinner-light.gif diff --git a/apps/admin/public/images/plane-takeoff.png b/apps/admin/app/assets/images/plane-takeoff.png similarity index 100% rename from apps/admin/public/images/plane-takeoff.png rename to apps/admin/app/assets/images/plane-takeoff.png diff --git a/apps/admin/public/instance/instance-failure-dark.svg b/apps/admin/app/assets/instance/instance-failure-dark.svg similarity index 100% rename from apps/admin/public/instance/instance-failure-dark.svg rename to apps/admin/app/assets/instance/instance-failure-dark.svg diff --git a/apps/admin/public/instance/instance-failure.svg b/apps/admin/app/assets/instance/instance-failure.svg similarity index 100% rename from apps/admin/public/instance/instance-failure.svg rename to apps/admin/app/assets/instance/instance-failure.svg diff --git a/apps/admin/app/assets/logos/gitea-logo.svg b/apps/admin/app/assets/logos/gitea-logo.svg new file mode 100644 index 0000000000..43291345df --- /dev/null +++ b/apps/admin/app/assets/logos/gitea-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/admin/public/logos/github-black.png b/apps/admin/app/assets/logos/github-black.png similarity index 100% rename from apps/admin/public/logos/github-black.png rename to apps/admin/app/assets/logos/github-black.png diff --git a/apps/admin/public/logos/github-white.png b/apps/admin/app/assets/logos/github-white.png similarity index 100% rename from apps/admin/public/logos/github-white.png rename to apps/admin/app/assets/logos/github-white.png diff --git a/apps/admin/public/logos/gitlab-logo.svg b/apps/admin/app/assets/logos/gitlab-logo.svg similarity index 100% rename from apps/admin/public/logos/gitlab-logo.svg rename to apps/admin/app/assets/logos/gitlab-logo.svg diff --git a/apps/admin/public/logos/google-logo.svg b/apps/admin/app/assets/logos/google-logo.svg similarity index 100% rename from apps/admin/public/logos/google-logo.svg rename to apps/admin/app/assets/logos/google-logo.svg diff --git a/apps/admin/public/logos/oidc-logo.svg b/apps/admin/app/assets/logos/oidc-logo.svg similarity index 100% rename from apps/admin/public/logos/oidc-logo.svg rename to apps/admin/app/assets/logos/oidc-logo.svg diff --git a/apps/admin/public/logos/saml-logo.svg b/apps/admin/app/assets/logos/saml-logo.svg similarity index 100% rename from apps/admin/public/logos/saml-logo.svg rename to apps/admin/app/assets/logos/saml-logo.svg diff --git a/apps/admin/public/logos/takeoff-icon-dark.svg b/apps/admin/app/assets/logos/takeoff-icon-dark.svg similarity index 100% rename from apps/admin/public/logos/takeoff-icon-dark.svg rename to apps/admin/app/assets/logos/takeoff-icon-dark.svg diff --git a/apps/admin/public/logos/takeoff-icon-light.svg b/apps/admin/app/assets/logos/takeoff-icon-light.svg similarity index 100% rename from apps/admin/public/logos/takeoff-icon-light.svg rename to apps/admin/app/assets/logos/takeoff-icon-light.svg diff --git a/apps/admin/app/compat/next/helper.ts b/apps/admin/app/compat/next/helper.ts new file mode 100644 index 0000000000..fe1a984460 --- /dev/null +++ b/apps/admin/app/compat/next/helper.ts @@ -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; + } +} diff --git a/apps/admin/app/compat/next/image.tsx b/apps/admin/app/compat/next/image.tsx new file mode 100644 index 0000000000..91de8b7810 --- /dev/null +++ b/apps/admin/app/compat/next/image.tsx @@ -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 & { + src: string; +}; + +const Image: React.FC = ({ src, alt = "", ...rest }) => {alt}; + +export default Image; diff --git a/apps/admin/app/compat/next/link.tsx b/apps/admin/app/compat/next/link.tsx new file mode 100644 index 0000000000..4f42363272 --- /dev/null +++ b/apps/admin/app/compat/next/link.tsx @@ -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 = ({ + href, + replace, + prefetch: _prefetch, + scroll: _scroll, + shallow: _shallow, + ...rest +}) => ; + +export default Link; diff --git a/apps/admin/app/compat/next/navigation.ts b/apps/admin/app/compat/next/navigation.ts new file mode 100644 index 0000000000..27ee033403 --- /dev/null +++ b/apps/admin/app/compat/next/navigation.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useMemo } from "react"; +import { useLocation, useNavigate, 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; +} diff --git a/apps/admin/app/components/404.tsx b/apps/admin/app/components/404.tsx new file mode 100644 index 0000000000..fa17e4fcdc --- /dev/null +++ b/apps/admin/app/components/404.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Link } from "react-router"; +// ui +import { Button } from "@plane/propel/button"; +// images +import Image404 from "@/app/assets/images/404.svg?url"; + +const PageNotFound = () => ( +
+
+
+
+ 404 - Page not found +
+
+

Oops! Something went wrong.

+

+ Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is + temporarily unavailable. +

+
+ + + + + +
+
+
+); + +export default PageNotFound; diff --git a/apps/admin/app/error.tsx b/apps/admin/app/error.tsx deleted file mode 100644 index 76794e04a7..0000000000 --- a/apps/admin/app/error.tsx +++ /dev/null @@ -1,9 +0,0 @@ -"use client"; - -export default function RootErrorPage() { - return ( -
-

Something went wrong.

-
- ); -} diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx deleted file mode 100644 index b9cdd17caf..0000000000 --- a/apps/admin/app/layout.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { ReactNode } from "react"; -import type { Metadata } from "next"; -// plane imports -import { ADMIN_BASE_PATH } from "@plane/constants"; -// styles -import "@/styles/globals.css"; - -export const metadata: Metadata = { - title: "Plane | Simple, extensible, open-source project management tool.", - description: - "Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.", - openGraph: { - title: "Plane | Simple, extensible, open-source project management tool.", - description: - "Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.", - url: "https://plane.so/", - }, - keywords: - "software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration", - twitter: { - site: "@planepowers", - }, -}; - -export default function RootLayout({ children }: { children: ReactNode }) { - const ASSET_PREFIX = ADMIN_BASE_PATH; - return ( - - - - - - - - - {children} - - ); -} diff --git a/apps/admin/app/(all)/layout.tsx b/apps/admin/app/providers.tsx similarity index 61% rename from apps/admin/app/(all)/layout.tsx rename to apps/admin/app/providers.tsx index ddfba732a9..841e4cf38f 100644 --- a/apps/admin/app/(all)/layout.tsx +++ b/apps/admin/app/providers.tsx @@ -2,24 +2,25 @@ import { ThemeProvider } from "next-themes"; import { SWRConfig } from "swr"; -// providers -import { InstanceProvider } from "./instance.provider"; -import { StoreProvider } from "./store.provider"; -import { ToastWithTheme } from "./toast"; -import { UserProvider } from "./user.provider"; +import { AppProgressBar } from "@/lib/b-progress"; +import { InstanceProvider } from "./(all)/instance.provider"; +import { StoreProvider } from "./(all)/store.provider"; +import { ToastWithTheme } from "./(all)/toast"; +import { UserProvider } from "./(all)/user.provider"; const DEFAULT_SWR_CONFIG = { refreshWhenHidden: false, revalidateIfStale: false, revalidateOnFocus: false, revalidateOnMount: true, - refreshInterval: 600000, + refreshInterval: 600_000, errorRetryCount: 3, }; -export default function InstanceLayout({ children }: { children: React.ReactNode }) { +export function AppProviders({ children }: { children: React.ReactNode }) { return ( + diff --git a/apps/admin/app/root.tsx b/apps/admin/app/root.tsx new file mode 100644 index 0000000000..746c2099ef --- /dev/null +++ b/apps/admin/app/root.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from "react"; +import { Links, Meta, Outlet, Scripts } from "react-router"; +import type { LinksFunction } from "react-router"; +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"; +import globalStyles from "@/styles/globals.css?url"; +import type { Route } from "./+types/root"; +import { AppProviders } from "./providers"; + +const APP_TITLE = "Plane | Simple, extensible, open-source project management tool."; +const APP_DESCRIPTION = + "Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind."; + +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` }, + { rel: "stylesheet", href: globalStyles }, +]; + +export function Layout({ children }: { children: ReactNode }) { + return ( + + + + + + + + + {children} + + + + ); +} + +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://plane.so/" }, + { + name: "keywords", + content: + "software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration", + }, + { name: "twitter:site", content: "@planepowers" }, +]; + +export default function Root() { + return ; +} + +export function HydrateFallback() { + return null; +} + +export function ErrorBoundary() { + return ( +
+

Something went wrong.

+
+ ); +} diff --git a/apps/admin/app/routes.ts b/apps/admin/app/routes.ts new file mode 100644 index 0000000000..0f7232439f --- /dev/null +++ b/apps/admin/app/routes.ts @@ -0,0 +1,21 @@ +import { index, layout, route } from "@react-router/dev/routes"; +import type { RouteConfig } from "@react-router/dev/routes"; + +export default [ + layout("./(all)/(home)/layout.tsx", [index("./(all)/(home)/page.tsx")]), + layout("./(all)/(dashboard)/layout.tsx", [ + route("general", "./(all)/(dashboard)/general/page.tsx"), + route("workspace", "./(all)/(dashboard)/workspace/page.tsx"), + route("workspace/create", "./(all)/(dashboard)/workspace/create/page.tsx"), + route("email", "./(all)/(dashboard)/email/page.tsx"), + route("authentication", "./(all)/(dashboard)/authentication/page.tsx"), + route("authentication/github", "./(all)/(dashboard)/authentication/github/page.tsx"), + route("authentication/gitlab", "./(all)/(dashboard)/authentication/gitlab/page.tsx"), + route("authentication/google", "./(all)/(dashboard)/authentication/google/page.tsx"), + route("authentication/gitea", "./(all)/(dashboard)/authentication/gitea/page.tsx"), + route("ai", "./(all)/(dashboard)/ai/page.tsx"), + route("image", "./(all)/(dashboard)/image/page.tsx"), + ]), + // Catch-all route for 404 handling - must be last + route("*", "./components/404.tsx"), +] satisfies RouteConfig; diff --git a/apps/admin/app/types/next-image.d.ts b/apps/admin/app/types/next-image.d.ts new file mode 100644 index 0000000000..a81e721614 --- /dev/null +++ b/apps/admin/app/types/next-image.d.ts @@ -0,0 +1,5 @@ +declare module "next/image" { + type Props = React.ComponentProps<"img"> & { src: string }; + const Image: React.FC; + export default Image; +} diff --git a/apps/admin/app/types/next-link.d.ts b/apps/admin/app/types/next-link.d.ts new file mode 100644 index 0000000000..c724e3aec1 --- /dev/null +++ b/apps/admin/app/types/next-link.d.ts @@ -0,0 +1,12 @@ +declare module "next/link" { + type Props = React.ComponentProps<"a"> & { + href: string; + replace?: boolean; + prefetch?: boolean; + scroll?: boolean; + shallow?: boolean; + }; + + const Link: React.FC; + export default Link; +} diff --git a/apps/admin/app/types/next-navigation.d.ts b/apps/admin/app/types/next-navigation.d.ts new file mode 100644 index 0000000000..7a7a7f1541 --- /dev/null +++ b/apps/admin/app/types/next-navigation.d.ts @@ -0,0 +1,13 @@ +declare module "next/navigation" { + export function useRouter(): { + push: (to: string) => void; + replace: (to: string) => void; + back: () => void; + forward: () => void; + refresh: () => void; + prefetch: (to: string) => Promise | void; + }; + + export function usePathname(): string; + export function useSearchParams(): URLSearchParams; +} diff --git a/apps/admin/app/types/react-router-virtual.d.ts b/apps/admin/app/types/react-router-virtual.d.ts new file mode 100644 index 0000000000..abf3b638e4 --- /dev/null +++ b/apps/admin/app/types/react-router-virtual.d.ts @@ -0,0 +1,5 @@ +declare module "virtual:react-router/server-build" { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const build: any; + export default build; +} diff --git a/apps/admin/ce/components/authentication/authentication-modes.tsx b/apps/admin/ce/components/authentication/authentication-modes.tsx index 386e0c05e2..1e3bb726f4 100644 --- a/apps/admin/ce/components/authentication/authentication-modes.tsx +++ b/apps/admin/ce/components/authentication/authentication-modes.tsx @@ -10,8 +10,16 @@ import type { } from "@plane/types"; import { resolveGeneralTheme } from "@plane/utils"; // components +import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; +import githubLightModeImage from "@/app/assets/logos/github-black.png?url"; +import githubDarkModeImage from "@/app/assets/logos/github-white.png?url"; +import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; +import GoogleLogo from "@/app/assets/logos/google-logo.svg?url"; +import OIDCLogo from "@/app/assets/logos/oidc-logo.svg?url"; +import SAMLLogo from "@/app/assets/logos/saml-logo.svg?url"; import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; +import { GiteaConfiguration } from "@/components/authentication/gitea-config"; import { GithubConfiguration } from "@/components/authentication/github-config"; import { GitlabConfiguration } from "@/components/authentication/gitlab-config"; import { GoogleConfiguration } from "@/components/authentication/google-config"; @@ -19,12 +27,6 @@ import { PasswordLoginConfiguration } from "@/components/authentication/password // plane admin components import { UpgradeButton } from "@/plane-admin/components/common"; // assets -import githubLightModeImage from "@/public/logos/github-black.png"; -import githubDarkModeImage from "@/public/logos/github-white.png"; -import GitlabLogo from "@/public/logos/gitlab-logo.svg"; -import GoogleLogo from "@/public/logos/google-logo.svg"; -import OIDCLogo from "@/public/logos/oidc-logo.svg"; -import SAMLLogo from "@/public/logos/saml-logo.svg"; export type TAuthenticationModeProps = { disabled: boolean; @@ -80,6 +82,13 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => icon: GitLab Logo, config: , }, + { + key: "gitea", + name: "Gitea", + description: "Allow members to log in or sign up to plane with their Gitea accounts.", + icon: Gitea Logo, + config: , + }, { key: "oidc", name: "OIDC", @@ -98,7 +107,7 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => }, ]; -export const AuthenticationModes: React.FC = observer((props) => { +export const AuthenticationModes = observer>((props) => { const { disabled, updateConfig } = props; // next-themes const { resolvedTheme } = useTheme(); diff --git a/apps/admin/core/components/authentication/authentication-method-card.tsx b/apps/admin/core/components/authentication/authentication-method-card.tsx index df8e6dba63..c1f613a96b 100644 --- a/apps/admin/core/components/authentication/authentication-method-card.tsx +++ b/apps/admin/core/components/authentication/authentication-method-card.tsx @@ -1,6 +1,5 @@ "use client"; -import type { FC } from "react"; // helpers import { cn } from "@plane/utils"; @@ -14,7 +13,7 @@ type Props = { unavailable?: boolean; }; -export const AuthenticationMethodCard: FC = (props) => { +export const AuthenticationMethodCard: React.FC = (props) => { const { name, description, icon, config, disabled = false, withBorder = true, unavailable = false } = props; return ( diff --git a/apps/admin/core/components/authentication/gitea-config.tsx b/apps/admin/core/components/authentication/gitea-config.tsx new file mode 100644 index 0000000000..8b0b9b3745 --- /dev/null +++ b/apps/admin/core/components/authentication/gitea-config.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +// icons +import { Settings2 } from "lucide-react"; +// plane internal packages +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const GiteaConfiguration: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const GiteaConfig = formattedConfig?.IS_GITEA_ENABLED ?? ""; + const GiteaConfigured = + !!formattedConfig?.GITEA_HOST && !!formattedConfig?.GITEA_CLIENT_ID && !!formattedConfig?.GITEA_CLIENT_SECRET; + + return ( + <> + {GiteaConfigured ? ( +
+ + Edit + + { + Boolean(parseInt(GiteaConfig)) === true + ? updateConfig("IS_GITEA_ENABLED", "0") + : updateConfig("IS_GITEA_ENABLED", "1"); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/apps/admin/core/components/authentication/gitlab-config.tsx b/apps/admin/core/components/authentication/gitlab-config.tsx index 6f0294c3cd..d491ec1454 100644 --- a/apps/admin/core/components/authentication/gitlab-config.tsx +++ b/apps/admin/core/components/authentication/gitlab-config.tsx @@ -1,6 +1,5 @@ "use client"; -import React from "react"; import { observer } from "mobx-react"; import Link from "next/link"; // icons diff --git a/apps/admin/core/components/authentication/google-config.tsx b/apps/admin/core/components/authentication/google-config.tsx index ae0cecf338..a87c6027e5 100644 --- a/apps/admin/core/components/authentication/google-config.tsx +++ b/apps/admin/core/components/authentication/google-config.tsx @@ -1,6 +1,5 @@ "use client"; -import React from "react"; import { observer } from "mobx-react"; import Link from "next/link"; // icons diff --git a/apps/admin/core/components/common/empty-state.tsx b/apps/admin/core/components/common/empty-state.tsx index 4bf291f5c5..dba49f6055 100644 --- a/apps/admin/core/components/common/empty-state.tsx +++ b/apps/admin/core/components/common/empty-state.tsx @@ -7,7 +7,7 @@ import { Button } from "@plane/propel/button"; type Props = { title: string; description?: React.ReactNode; - image?: any; + image?: string; primaryButton?: { icon?: any; text: string; diff --git a/apps/admin/core/components/common/logo-spinner.tsx b/apps/admin/core/components/common/logo-spinner.tsx index fda44fca59..a12dfcabbc 100644 --- a/apps/admin/core/components/common/logo-spinner.tsx +++ b/apps/admin/core/components/common/logo-spinner.tsx @@ -1,8 +1,7 @@ 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(); diff --git a/apps/admin/core/components/instance/failure.tsx b/apps/admin/core/components/instance/failure.tsx index 97ace834f8..bfe9fc2777 100644 --- a/apps/admin/core/components/instance/failure.tsx +++ b/apps/admin/core/components/instance/failure.tsx @@ -1,15 +1,14 @@ "use client"; -import type { FC } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; import { useTheme } from "next-themes"; import { Button } from "@plane/propel/button"; // assets import { AuthHeader } from "@/app/(all)/(home)/auth-header"; -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 = observer(() => { +export const InstanceFailureView: React.FC = observer(() => { const { resolvedTheme } = useTheme(); const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; diff --git a/apps/admin/core/components/instance/instance-not-ready.tsx b/apps/admin/core/components/instance/instance-not-ready.tsx index b01d938bf9..56cc1a5433 100644 --- a/apps/admin/core/components/instance/instance-not-ready.tsx +++ b/apps/admin/core/components/instance/instance-not-ready.tsx @@ -1,13 +1,12 @@ "use client"; -import type { FC } from "react"; import Image from "next/image"; import Link from "next/link"; import { Button } from "@plane/propel/button"; // assets -import PlaneTakeOffImage from "@/public/images/plane-takeoff.png"; +import PlaneTakeOffImage from "@/app/assets/images/plane-takeoff.png?url"; -export const InstanceNotReady: FC = () => ( +export const InstanceNotReady: React.FC = () => (
diff --git a/apps/admin/core/components/instance/loading.tsx b/apps/admin/core/components/instance/loading.tsx index 27dc4ae6e6..b664d00c2b 100644 --- a/apps/admin/core/components/instance/loading.tsx +++ b/apps/admin/core/components/instance/loading.tsx @@ -1,8 +1,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 InstanceLoading = () => { const { resolvedTheme } = useTheme(); diff --git a/apps/admin/core/components/instance/setup-form.tsx b/apps/admin/core/components/instance/setup-form.tsx index a4d59b6895..eae4c6d87b 100644 --- a/apps/admin/core/components/instance/setup-form.tsx +++ b/apps/admin/core/components/instance/setup-form.tsx @@ -1,6 +1,5 @@ "use client"; -import type { FC } from "react"; import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; // icons @@ -54,7 +53,7 @@ const defaultFromData: TFormData = { is_telemetry_enabled: true, }; -export const InstanceSetupForm: FC = (props) => { +export const InstanceSetupForm: React.FC = (props) => { const {} = props; // search params const searchParams = useSearchParams(); diff --git a/apps/admin/core/components/new-user-popup.tsx b/apps/admin/core/components/new-user-popup.tsx index 4f0e0236bc..3ada29c2d8 100644 --- a/apps/admin/core/components/new-user-popup.tsx +++ b/apps/admin/core/components/new-user-popup.tsx @@ -1,24 +1,23 @@ "use client"; -import React from "react"; import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; -import { useTheme as nextUseTheme } from "next-themes"; +import { useTheme as useNextTheme } from "next-themes"; // ui import { Button, getButtonStyling } from "@plane/propel/button"; import { resolveGeneralTheme } from "@plane/utils"; // hooks +import TakeoffIconDark from "@/app/assets/logos/takeoff-icon-dark.svg?url"; +import TakeoffIconLight from "@/app/assets/logos/takeoff-icon-light.svg?url"; import { useTheme } from "@/hooks/store"; // icons -import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg"; -import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg"; -export const NewUserPopup: React.FC = observer(() => { +export const NewUserPopup = observer(() => { // hooks const { isNewUserPopup, toggleNewUserPopup } = useTheme(); // theme - const { resolvedTheme } = nextUseTheme(); + const { resolvedTheme } = useNextTheme(); if (!isNewUserPopup) return <>; return ( diff --git a/apps/admin/core/lib/b-progress/AppProgressBar.tsx b/apps/admin/core/lib/b-progress/AppProgressBar.tsx new file mode 100644 index 0000000000..1bd9362c01 --- /dev/null +++ b/apps/admin/core/lib/b-progress/AppProgressBar.tsx @@ -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 = { + 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 ( + * <> + * + * + * + * ); + * } + * ``` + */ +export function AppProgressBar(): null { + const navigation = useNavigation(); + const timerRef = useRef | null>(null); + const startedRef = useRef(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; +} diff --git a/apps/admin/core/lib/b-progress/index.tsx b/apps/admin/core/lib/b-progress/index.tsx new file mode 100644 index 0000000000..7b531da2b2 --- /dev/null +++ b/apps/admin/core/lib/b-progress/index.tsx @@ -0,0 +1 @@ +export * from "./AppProgressBar"; diff --git a/apps/admin/core/utils/public-asset.ts b/apps/admin/core/utils/public-asset.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/apps/admin/core/utils/public-asset.ts @@ -0,0 +1 @@ +export {}; diff --git a/apps/admin/middleware.js b/apps/admin/middleware.js new file mode 100644 index 0000000000..6d9e71f07e --- /dev/null +++ b/apps/admin/middleware.js @@ -0,0 +1,14 @@ +import { next } from '@vercel/edge'; + +export default function middleware() { + return next({ + headers: { + 'Referrer-Policy': 'origin-when-cross-origin', + 'X-Frame-Options': 'DENY', + 'X-Content-Type-Options': 'nosniff', + 'X-DNS-Prefetch-Control': 'on', + 'Strict-Transport-Security': + 'max-age=31536000; includeSubDomains; preload', + }, + }); +} \ No newline at end of file diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts deleted file mode 100644 index 40c3d68096..0000000000 --- a/apps/admin/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js deleted file mode 100644 index c848e0b925..0000000000 --- a/apps/admin/next.config.js +++ /dev/null @@ -1,29 +0,0 @@ -/** @type {import('next').NextConfig} */ - -const nextConfig = { - trailingSlash: true, - reactStrictMode: false, - swcMinify: true, - output: "standalone", - images: { - unoptimized: true, - }, - basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "", - 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; diff --git a/apps/admin/nginx/nginx.conf b/apps/admin/nginx/nginx.conf new file mode 100644 index 0000000000..243aebff54 --- /dev/null +++ b/apps/admin/nginx/nginx.conf @@ -0,0 +1,29 @@ +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/ /god-mode/index.html; + } + } +} \ No newline at end of file diff --git a/apps/admin/package.json b/apps/admin/package.json index 02ff364039..b66a1ffe0a 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,22 +1,24 @@ { "name": "admin", "description": "Admin UI for Plane", - "version": "1.0.0", + "version": "1.1.0", "license": "AGPL-3.0", "private": true, + "type": "module", "scripts": { - "dev": "next dev --port 3001", - "build": "next build", - "preview": "next build && next start", - "start": "next start", - "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist", + "dev": "cross-env NODE_ENV=development PORT=3001 node server.mjs", + "build": "react-router build", + "preview": "react-router build && cross-env NODE_ENV=production PORT=3001 node server.mjs", + "start": "serve -s build/client -l 3001", + "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist && rm -rf build", "check:lint": "eslint . --max-warnings 19", - "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:", "@headlessui/react": "^1.7.19", "@plane/constants": "workspace:*", "@plane/hooks": "workspace:*", @@ -25,19 +27,30 @@ "@plane/types": "workspace:*", "@plane/ui": "workspace:*", "@plane/utils": "workspace:*", - "autoprefixer": "10.4.14", + "@react-router/express": "^7.9.3", + "@react-router/node": "^7.9.3", + "@tanstack/react-virtual": "^3.13.12", + "@tanstack/virtual-core": "^3.13.12", + "@vercel/edge": "1.2.2", "axios": "catalog:", + "compression": "^1.8.1", + "cross-env": "^7.0.3", + "dotenv": "^16.4.5", + "express": "^5.1.0", + "http-proxy-middleware": "^3.0.5", + "isbot": "^5.1.31", "lodash-es": "catalog:", "lucide-react": "catalog:", "mobx": "catalog:", "mobx-react": "catalog:", - "next": "catalog:", + "morgan": "^1.10.1", "next-themes": "^0.2.1", - "postcss": "^8.4.49", "react": "catalog:", "react-dom": "catalog:", "react-hook-form": "7.51.5", - "sharp": "catalog:", + "react-router": "^7.9.1", + "react-router-dom": "^7.9.1", + "serve": "14.2.5", "swr": "catalog:", "uuid": "catalog:" }, @@ -45,10 +58,16 @@ "@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/react": "catalog:", "@types/react-dom": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vite": "7.1.7", + "vite-tsconfig-paths": "^5.1.4" } } diff --git a/apps/admin/postcss.config.js b/apps/admin/postcss.config.cjs similarity index 100% rename from apps/admin/postcss.config.js rename to apps/admin/postcss.config.cjs diff --git a/apps/admin/public/.well-known/appspecific/com.chrome.devtools.json b/apps/admin/public/.well-known/appspecific/com.chrome.devtools.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/apps/admin/public/.well-known/appspecific/com.chrome.devtools.json @@ -0,0 +1 @@ +{} diff --git a/apps/admin/react-router.config.ts b/apps/admin/react-router.config.ts new file mode 100644 index 0000000000..aaeca700bf --- /dev/null +++ b/apps/admin/react-router.config.ts @@ -0,0 +1,8 @@ +import type { Config } from "@react-router/dev/config"; + +export default { + appDirectory: "app", + basename: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH, + // Admin runs as a client-side app; build a static client bundle only + ssr: false, +} satisfies Config; diff --git a/apps/admin/server.mjs b/apps/admin/server.mjs new file mode 100644 index 0000000000..90f17cf689 --- /dev/null +++ b/apps/admin/server.mjs @@ -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_ADMIN_BASE_URL when available, otherwise +// default to http://localhost:3001 and fall back to PORT env if explicitly set. +const DEFAULT_BASE_URL = "http://localhost:3001"; +const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || DEFAULT_BASE_URL; +let parsedBaseUrl; +try { + parsedBaseUrl = new URL(ADMIN_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); +}); diff --git a/apps/admin/server/app.ts b/apps/admin/server/app.ts new file mode 100644 index 0000000000..70a61c3852 --- /dev/null +++ b/apps/admin/server/app.ts @@ -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_ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH + ? process.env.NEXT_PUBLIC_ADMIN_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_ADMIN_BASE_PATH, router); diff --git a/apps/admin/styles/globals.css b/apps/admin/styles/globals.css index 86a0b85183..53c55a5334 100644 --- a/apps/admin/styles/globals.css +++ b/apps/admin/styles/globals.css @@ -1,4 +1,4 @@ -@import "@plane/propel/styles/fonts"; +@import "@plane/propel/styles/fonts.css"; @tailwind base; @tailwind components; @@ -394,3 +394,30 @@ body { :-ms-input-placeholder { color: rgb(var(--color-text-400)); } + +/* 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; +} diff --git a/apps/admin/tailwind.config.js b/apps/admin/tailwind.config.cjs similarity index 100% rename from apps/admin/tailwind.config.js rename to apps/admin/tailwind.config.cjs diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json index d85abf2cc9..7dbd2eeb92 100644 --- a/apps/admin/tsconfig.json +++ b/apps/admin/tsconfig.json @@ -1,21 +1,17 @@ { - "extends": "@plane/typescript-config/nextjs.json", + "extends": "@plane/typescript-config/react-router.json", "compilerOptions": { - "plugins": [ - { - "name": "next" - } - ], "baseUrl": ".", + "rootDirs": [".", "./.react-router/types"], + "types": ["node", "vite/client"], "paths": { "@/app/*": ["app/*"], "@/*": ["core/*"], - "@/public/*": ["public/*"], "@/plane-admin/*": ["ce/*"], "@/styles/*": ["styles/*"] }, "strictNullChecks": true }, - "include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], "exclude": ["node_modules"] } diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts new file mode 100644 index 0000000000..30b2be3da9 --- /dev/null +++ b/apps/admin/vite.config.ts @@ -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>((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.ADMIN_ENABLE_SSR_BUILD === "true"; + const basePath = joinUrlPath(process.env.NEXT_PUBLIC_ADMIN_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 admin + "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 + }; +}); diff --git a/apps/api/package.json b/apps/api/package.json index 97122880ff..ffecb3a73e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "plane-api", - "version": "1.0.0", + "version": "1.1.0", "license": "AGPL-3.0", "private": true, "description": "API server powering Plane's backend" diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 7596915eb4..b58b9fdcb7 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -53,3 +53,5 @@ from .asset import ( GenericAssetUpdateSerializer, FileAssetSerializer, ) +from .invite import WorkspaceInviteSerializer +from .member import ProjectMemberSerializer \ No newline at end of file diff --git a/apps/api/plane/api/serializers/intake.py b/apps/api/plane/api/serializers/intake.py index fcfedcbd62..5fd846d1d8 100644 --- a/apps/api/plane/api/serializers/intake.py +++ b/apps/api/plane/api/serializers/intake.py @@ -44,24 +44,7 @@ class IntakeIssueCreateSerializer(BaseSerializer): class Meta: model = IntakeIssue - fields = [ - "issue", - "intake", - "status", - "snoozed_till", - "duplicate_to", - "source", - "source_email", - ] - read_only_fields = [ - "id", - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] + fields = ["issue"] class IntakeIssueSerializer(BaseSerializer): diff --git a/apps/api/plane/api/serializers/invite.py b/apps/api/plane/api/serializers/invite.py new file mode 100644 index 0000000000..5b52dc03c4 --- /dev/null +++ b/apps/api/plane/api/serializers/invite.py @@ -0,0 +1,56 @@ +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from rest_framework import serializers + +# Module imports +from plane.db.models import WorkspaceMemberInvite +from .base import BaseSerializer +from plane.app.permissions.base import ROLE + + +class WorkspaceInviteSerializer(BaseSerializer): + """ + Serializer for workspace invites. + """ + + class Meta: + model = WorkspaceMemberInvite + fields = [ + "id", + "email", + "role", + "created_at", + "updated_at", + "responded_at", + "accepted", + ] + read_only_fields = [ + "id", + "workspace", + "created_at", + "updated_at", + "responded_at", + "accepted", + ] + + def validate_email(self, value): + try: + validate_email(value) + except ValidationError: + raise serializers.ValidationError("Invalid email address", code="INVALID_EMAIL_ADDRESS") + return value + + def validate_role(self, value): + if value not in [ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value]: + raise serializers.ValidationError("Invalid role", code="INVALID_WORKSPACE_MEMBER_ROLE") + return value + + def validate(self, data): + slug = self.context["slug"] + if ( + data.get("email") + and WorkspaceMemberInvite.objects.filter(email=data["email"], workspace__slug=slug).exists() + ): + raise serializers.ValidationError("Email already invited", code="EMAIL_ALREADY_INVITED") + return data diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py index d7fc3e911d..d86dfa6b6e 100644 --- a/apps/api/plane/api/serializers/issue.py +++ b/apps/api/plane/api/serializers/issue.py @@ -393,7 +393,7 @@ class IssueLinkCreateSerializer(BaseSerializer): class Meta: model = IssueLink - fields = ["url", "issue_id"] + fields = ["title", "url", "issue_id"] read_only_fields = [ "id", "workspace", diff --git a/apps/api/plane/api/serializers/member.py b/apps/api/plane/api/serializers/member.py new file mode 100644 index 0000000000..3aa9644b4c --- /dev/null +++ b/apps/api/plane/api/serializers/member.py @@ -0,0 +1,39 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from plane.db.models import ProjectMember, WorkspaceMember +from .base import BaseSerializer +from plane.db.models import User +from plane.utils.permissions import ROLE + + +class ProjectMemberSerializer(BaseSerializer): + """ + Serializer for project members. + """ + + member = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), + required=True, + ) + + def validate_member(self, value): + slug = self.context.get("slug") + if not slug: + raise serializers.ValidationError("Slug is required", code="INVALID_SLUG") + if not value: + raise serializers.ValidationError("Member is required", code="INVALID_MEMBER") + if not WorkspaceMember.objects.filter(workspace__slug=slug, member=value).exists(): + raise serializers.ValidationError("Member not found in workspace", code="INVALID_MEMBER") + return value + + def validate_role(self, value): + if value not in [ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value]: + raise serializers.ValidationError("Invalid role", code="INVALID_ROLE") + return value + + class Meta: + model = ProjectMember + fields = ["id", "member", "role"] + read_only_fields = ["id"] diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py index 3228c5ad91..5b30703611 100644 --- a/apps/api/plane/api/serializers/project.py +++ b/apps/api/plane/api/serializers/project.py @@ -1,4 +1,5 @@ # Third party imports +import random from rest_framework import serializers # Module imports @@ -24,6 +25,47 @@ class ProjectCreateSerializer(BaseSerializer): and workspace association for new project initialization. """ + PROJECT_ICON_DEFAULT_COLORS = [ + "#95999f", + "#6d7b8a", + "#5e6ad2", + "#02b5ed", + "#02b55c", + "#f2be02", + "#e57a00", + "#f38e82", + ] + PROJECT_ICON_DEFAULT_ICONS = [ + "home", + "apps", + "settings", + "star", + "favorite", + "done", + "check_circle", + "add_task", + "create_new_folder", + "dataset", + "terminal", + "key", + "rocket", + "public", + "quiz", + "mood", + "gavel", + "eco", + "diamond", + "forest", + "bolt", + "sync", + "cached", + "library_add", + "view_timeline", + "view_kanban", + "empty_dashboard", + "cycle", + ] + class Meta: model = Project fields = [ @@ -44,10 +86,10 @@ class ProjectCreateSerializer(BaseSerializer): "archive_in", "close_in", "timezone", - "logo_props", "external_source", "external_id", "is_issue_type_enabled", + "is_time_tracking_enabled", ] read_only_fields = [ @@ -57,6 +99,7 @@ class ProjectCreateSerializer(BaseSerializer): "updated_at", "created_by", "updated_by", + "logo_props", ] def validate(self, data): @@ -86,6 +129,16 @@ class ProjectCreateSerializer(BaseSerializer): if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists(): raise serializers.ValidationError(detail="Project Identifier is taken") + if validated_data.get("logo_props", None) is None: + # Generate a random icon and color for the project icon + validated_data["logo_props"] = { + "in_use": "icon", + "icon": { + "name": random.choice(self.PROJECT_ICON_DEFAULT_ICONS), + "color": random.choice(self.PROJECT_ICON_DEFAULT_COLORS), + }, + } + project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"]) return project diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index 10cad2068e..d239b67887 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -8,6 +8,7 @@ from .project import urlpatterns as project_patterns from .state import urlpatterns as state_patterns from .user import urlpatterns as user_patterns from .work_item import urlpatterns as work_item_patterns +from .invite import urlpatterns as invite_patterns urlpatterns = [ *asset_patterns, @@ -20,4 +21,5 @@ urlpatterns = [ *state_patterns, *user_patterns, *work_item_patterns, + *invite_patterns, ] diff --git a/apps/api/plane/api/urls/cycle.py b/apps/api/plane/api/urls/cycle.py index bd7136aa2d..a2cab1fe69 100644 --- a/apps/api/plane/api/urls/cycle.py +++ b/apps/api/plane/api/urls/cycle.py @@ -46,7 +46,7 @@ urlpatterns = [ name="cycle-archive-unarchive", ), path( - "workspaces//projects//archived-cycles//unarchive/", + "workspaces//projects//archived-cycles//unarchive/", CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]), name="cycle-archive-unarchive", ), diff --git a/apps/api/plane/api/urls/invite.py b/apps/api/plane/api/urls/invite.py new file mode 100644 index 0000000000..9d73cb6ef8 --- /dev/null +++ b/apps/api/plane/api/urls/invite.py @@ -0,0 +1,18 @@ +# Django imports +from django.urls import path, include + +# Third party imports +from rest_framework.routers import DefaultRouter + +# Module imports +from plane.api.views import WorkspaceInvitationsViewset + + +# Create router with just the invitations prefix (no workspace slug) +router = DefaultRouter() +router.register(r"invitations", WorkspaceInvitationsViewset, basename="workspace-invitations") + +# Wrap the router URLs with the workspace slug path +urlpatterns = [ + path("workspaces//", include(router.urls)), +] \ No newline at end of file diff --git a/apps/api/plane/api/urls/member.py b/apps/api/plane/api/urls/member.py index a2b331ea1c..a33d8bbe35 100644 --- a/apps/api/plane/api/urls/member.py +++ b/apps/api/plane/api/urls/member.py @@ -1,13 +1,29 @@ from django.urls import path -from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint +from plane.api.views import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint urlpatterns = [ + # Project members path( "workspaces//projects//members/", - ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]), + ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="project-members", ), + path( + "workspaces//projects//members//", + ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]), + name="project-member", + ), + path( + "workspaces//projects//project-members/", + ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="project-members", + ), + path( + "workspaces//projects//project-members//", + ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]), + name="project-member", + ), path( "workspaces//members/", WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]), diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 8535d4858b..280c23bc23 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -43,7 +43,7 @@ from .module import ( ModuleArchiveUnarchiveAPIEndpoint, ) -from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint +from .member import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint from .intake import ( IntakeIssueListCreateAPIEndpoint, @@ -53,3 +53,5 @@ from .intake import ( from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint from .user import UserEndpoint + +from .invite import WorkspaceInvitationsViewset \ No newline at end of file diff --git a/apps/api/plane/api/views/base.py b/apps/api/plane/api/views/base.py index b3acbab360..f17ae2e328 100644 --- a/apps/api/plane/api/views/base.py +++ b/apps/api/plane/api/views/base.py @@ -1,5 +1,6 @@ # Python imports import zoneinfo +import logging # Django imports from django.conf import settings @@ -7,15 +8,19 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import IntegrityError from django.urls import resolve from django.utils import timezone -from plane.db.models.api import APIToken + +# Third party imports from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response - -# Third party imports +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import SearchFilter +from rest_framework.viewsets import ModelViewSet +from rest_framework.exceptions import APIException from rest_framework.generics import GenericAPIView # Module imports +from plane.db.models.api import APIToken from plane.api.middleware.api_authentication import APIKeyAuthentication from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle from plane.utils.exception_logger import log_exception @@ -23,6 +28,9 @@ from plane.utils.paginator import BasePaginator from plane.utils.core.mixins import ReadReplicaControlMixin +logger = logging.getLogger("plane.api") + + class TimezoneMixin: """ This enables timezone conversion according @@ -152,3 +160,118 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa def expand(self): expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand] return expand if expand else None + + +class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePaginator): + model = None + + authentication_classes = [APIKeyAuthentication] + permission_classes = [ + IsAuthenticated, + ] + use_read_replica = False + + def get_queryset(self): + try: + return self.model.objects.all() + except Exception as e: + log_exception(e) + raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) + + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + if isinstance(e, IntegrityError): + log_exception(e) + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + logger.warning( + "Validation Error", + extra={ + "error_code": "VALIDATION_ERROR", + "error_message": str(e), + }, + ) + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + logger.warning( + "Object Does Not Exist", + extra={ + "error_code": "OBJECT_DOES_NOT_EXIST", + "error_message": str(e), + }, + ) + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + logger.error( + "Key Error", + extra={ + "error_code": "KEY_ERROR", + "error_message": str(e), + }, + ) + return Response( + {"error": "The required key does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + log_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") + + return response + except Exception as exc: + response = self.handle_exception(exc) + return response + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def project_id(self): + project_id = self.kwargs.get("project_id", None) + if project_id: + return project_id + + if resolve(self.request.path_info).url_name == "project": + return self.kwargs.get("pk", None) + + @property + def fields(self): + fields = [field for field in self.request.GET.get("fields", "").split(",") if field] + return fields if fields else None + + @property + def expand(self): + expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand] + return expand if expand else None diff --git a/apps/api/plane/api/views/cycle.py b/apps/api/plane/api/views/cycle.py index 1908ceada4..c92b27f591 100644 --- a/apps/api/plane/api/views/cycle.py +++ b/apps/api/plane/api/views/cycle.py @@ -12,13 +12,7 @@ from django.db.models import ( OuterRef, Q, Sum, - FloatField, - Case, - When, - Value, ) -from django.db.models.functions import Cast, Concat -from django.db import models # Third party imports from rest_framework import status @@ -47,7 +41,7 @@ from plane.db.models import ( ProjectMember, UserFavorite, ) -from plane.utils.analytics_plot import burndown_plot +from plane.utils.cycle_transfer_issues import transfer_cycle_issues from plane.utils.host import base_host from .base import BaseAPIView from plane.bgtasks.webhook_task import model_activity @@ -201,7 +195,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView): # Current Cycle if cycle_view == "current": - queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now()) + queryset = queryset.filter( + start_date__lte=timezone.now(), end_date__gte=timezone.now() + ) data = CycleSerializer( queryset, many=True, @@ -258,7 +254,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView): # Incomplete Cycles if cycle_view == "incomplete": - queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)) + queryset = queryset.filter( + Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True) + ) return self.paginate( request=request, queryset=(queryset), @@ -304,11 +302,17 @@ class CycleListCreateAPIEndpoint(BaseAPIView): Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes. """ - if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or ( - request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None + if ( + request.data.get("start_date", None) is None + and request.data.get("end_date", None) is None + ) or ( + request.data.get("start_date", None) is not None + and request.data.get("end_date", None) is not None ): - serializer = CycleCreateSerializer(data=request.data, context={"request": request}) + serializer = CycleCreateSerializer( + data=request.data, context={"request": request} + ) if serializer.is_valid(): if ( request.data.get("external_id") @@ -351,7 +355,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: return Response( - {"error": "Both start date and end date are either required or are to be null"}, + { + "error": "Both start date and end date are either required or are to be null" + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -499,7 +505,9 @@ class CycleDetailAPIEndpoint(BaseAPIView): """ cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder) + current_instance = json.dumps( + CycleSerializer(cycle).data, cls=DjangoJSONEncoder + ) if cycle.archived_at: return Response( @@ -512,14 +520,20 @@ class CycleDetailAPIEndpoint(BaseAPIView): if cycle.end_date is not None and cycle.end_date < timezone.now(): if "sort_order" in request_data: # Can only change sort order - request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)} + request_data = { + "sort_order": request_data.get("sort_order", cycle.sort_order) + } else: return Response( - {"error": "The Cycle has already been completed so it cannot be edited"}, + { + "error": "The Cycle has already been completed so it cannot be edited" + }, status=status.HTTP_400_BAD_REQUEST, ) - serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request}) + serializer = CycleUpdateSerializer( + cycle, data=request.data, partial=True, context={"request": request} + ) if serializer.is_valid(): if ( request.data.get("external_id") @@ -527,7 +541,9 @@ class CycleDetailAPIEndpoint(BaseAPIView): and Cycle.objects.filter( project_id=project_id, workspace__slug=slug, - external_source=request.data.get("external_source", cycle.external_source), + external_source=request.data.get( + "external_source", cycle.external_source + ), external_id=request.data.get("external_id"), ).exists() ): @@ -584,7 +600,11 @@ class CycleDetailAPIEndpoint(BaseAPIView): status=status.HTTP_403_FORBIDDEN, ) - cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True)) + cycle_issues = list( + CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( + "issue", flat=True + ) + ) issue_activity.delay( type="cycle.activity.deleted", @@ -604,7 +624,9 @@ class CycleDetailAPIEndpoint(BaseAPIView): # Delete the cycle cycle.delete() # Delete the user favorite cycle - UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete() + UserFavorite.objects.filter( + entity_type="cycle", entity_identifier=pk, project_id=project_id + ).delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -691,10 +713,10 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): ), ) ) - .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) + .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point__key")) .annotate( completed_estimates=Sum( - "issue_cycle__issue__estimate_point", + "issue_cycle__issue__estimate_point__key", filter=Q( issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, @@ -705,7 +727,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): ) .annotate( started_estimates=Sum( - "issue_cycle__issue__estimate_point", + "issue_cycle__issue__estimate_point__key", filter=Q( issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, @@ -742,7 +764,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): return self.paginate( request=request, queryset=(self.get_queryset()), - on_results=lambda cycles: CycleSerializer(cycles, many=True, fields=self.fields, expand=self.expand).data, + on_results=lambda cycles: CycleSerializer( + cycles, many=True, fields=self.fields, expand=self.expand + ).data, ) @cycle_docs( @@ -761,7 +785,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived. """ - cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug) + cycle = Cycle.objects.get( + pk=cycle_id, project_id=project_id, workspace__slug=slug + ) if cycle.end_date >= timezone.now(): return Response( {"error": "Only completed cycles can be archived"}, @@ -792,7 +818,9 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): Restore an archived cycle to active status, making it available for regular use. The cycle will reappear in active cycle lists. """ - cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug) + cycle = Cycle.objects.get( + pk=cycle_id, project_id=project_id, workspace__slug=slug + ) cycle.archived_at = None cycle.save() return Response(status=status.HTTP_204_NO_CONTENT) @@ -839,7 +867,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): request={}, responses={ 200: create_paginated_response( - CycleIssueSerializer, + IssueSerializer, "PaginatedCycleIssueResponse", "Paginated list of cycle work items", "Paginated Cycle Work Items", @@ -855,7 +883,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): # List order_by = request.GET.get("order_by", "created_at") issues = ( - Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True) + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True + ) .annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() @@ -892,7 +922,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): return self.paginate( request=request, queryset=(issues), - on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data, + on_results=lambda issues: IssueSerializer( + issues, many=True, fields=self.fields, expand=self.expand + ).data, ) @cycle_docs( @@ -922,10 +954,13 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): if not issues: return Response( - {"error": "Work items are required", "code": "MISSING_WORK_ITEMS"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Work items are required", "code": "MISSING_WORK_ITEMS"}, + status=status.HTTP_400_BAD_REQUEST, ) - cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id) + cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ) if cycle.end_date is not None and cycle.end_date < timezone.now(): return Response( @@ -937,9 +972,13 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): ) # Get all CycleWorkItems already created - cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)) + cycle_issues = list( + CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues) + ) existing_issues = [ - str(cycle_issue.issue_id) for cycle_issue in cycle_issues if str(cycle_issue.issue_id) in issues + str(cycle_issue.issue_id) + for cycle_issue in cycle_issues + if str(cycle_issue.issue_id) in issues ] new_issues = list(set(issues) - set(existing_issues)) @@ -990,7 +1029,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): current_instance=json.dumps( { "updated_cycle_issues": update_cycle_issue_activity, - "created_cycle_issues": serializers.serialize("json", created_records), + "created_cycle_issues": serializers.serialize( + "json", created_records + ), } ), epoch=int(timezone.now().timestamp()), @@ -1066,7 +1107,9 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView): cycle_id=cycle_id, issue_id=issue_id, ) - serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand) + serializer = CycleIssueSerializer( + cycle_issue, fields=self.fields, expand=self.expand + ) return Response(serializer.data, status=status.HTTP_200_OK) @cycle_docs( @@ -1171,406 +1214,34 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): {"error": "New Cycle Id is required"}, status=status.HTTP_400_BAD_REQUEST, ) - - new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first() - - old_cycle = ( - Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id) - .annotate( - total_issues=Count( - "issue_cycle", - filter=Q( - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__deleted_at__isnull=True, - issue_cycle__issue__deleted_at__isnull=True, - ), - ) - ) - .annotate( - completed_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__deleted_at__isnull=True, - issue_cycle__issue__deleted_at__isnull=True, - ), - ) - ) - .annotate( - cancelled_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="cancelled", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .annotate( - started_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .annotate( - unstarted_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="unstarted", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .annotate( - backlog_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="backlog", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - ) - old_cycle = old_cycle.first() - - estimate_type = Project.objects.filter( + + old_cycle = Cycle.objects.get( workspace__slug=slug, - pk=project_id, - estimate__isnull=False, - estimate__type="points", - ).exists() - - if estimate_type: - assignee_estimate_data = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(display_name=F("assignees__display_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .annotate( - avatar_url=Case( - # If `avatar_asset` exists, use it to generate the asset URL - When( - assignees__avatar_asset__isnull=False, - then=Concat( - Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field - Value("/"), - ), - ), - # If `avatar_asset` is None, fall back to using `avatar` field directly - When( - assignees__avatar_asset__isnull=True, - then="assignees__avatar", - ), - default=Value(None), - output_field=models.CharField(), - ) - ) - .values("display_name", "assignee_id", "avatar", "avatar_url") - .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) - .annotate( - completed_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("display_name") - ) - # assignee distribution serialization - assignee_estimate_distribution = [ - { - "display_name": item["display_name"], - "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None), - "avatar": item.get("avatar", None), - "avatar_url": item.get("avatar_url", None), - "total_estimates": item["total_estimates"], - "completed_estimates": item["completed_estimates"], - "pending_estimates": item["pending_estimates"], - } - for item in assignee_estimate_data - ] - - label_distribution_data = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) - .annotate( - completed_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - - estimate_completion_chart = burndown_plot( - queryset=old_cycle, - slug=slug, - project_id=project_id, - plot_type="points", - cycle_id=cycle_id, - ) - # Label distribution serialization - label_estimate_distribution = [ - { - "label_name": item["label_name"], - "color": item["color"], - "label_id": (str(item["label_id"]) if item["label_id"] else None), - "total_estimates": item["total_estimates"], - "completed_estimates": item["completed_estimates"], - "pending_estimates": item["pending_estimates"], - } - for item in label_distribution_data - ] - - # Get the assignee distribution - assignee_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(display_name=F("assignees__display_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .annotate( - avatar_url=Case( - # If `avatar_asset` exists, use it to generate the asset URL - When( - assignees__avatar_asset__isnull=False, - then=Concat( - Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field - Value("/"), - ), - ), - # If `avatar_asset` is None, fall back to using `avatar` field directly - When(assignees__avatar_asset__isnull=True, then="assignees__avatar"), - default=Value(None), - output_field=models.CharField(), - ) - ) - .values("display_name", "assignee_id", "avatar_url") - .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) - .annotate( - completed_issues=Count( - "id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("display_name") - ) - # assignee distribution serialized - assignee_distribution_data = [ - { - "display_name": item["display_name"], - "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None), - "avatar": item.get("avatar", None), - "avatar_url": item.get("avatar_url", None), - "total_issues": item["total_issues"], - "completed_issues": item["completed_issues"], - "pending_issues": item["pending_issues"], - } - for item in assignee_distribution - ] - - # Get the label distribution - label_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) - .annotate( - completed_issues=Count( - "id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - - # Label distribution serilization - label_distribution_data = [ - { - "label_name": item["label_name"], - "color": item["color"], - "label_id": (str(item["label_id"]) if item["label_id"] else None), - "total_issues": item["total_issues"], - "completed_issues": item["completed_issues"], - "pending_issues": item["pending_issues"], - } - for item in label_distribution - ] - - # Pass the new_cycle queryset to burndown_plot - completion_chart = burndown_plot( - queryset=old_cycle, - slug=slug, project_id=project_id, - plot_type="issues", - cycle_id=cycle_id, + pk=cycle_id, ) - - current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first() - - current_cycle.progress_snapshot = { - "total_issues": old_cycle.total_issues, - "completed_issues": old_cycle.completed_issues, - "cancelled_issues": old_cycle.cancelled_issues, - "started_issues": old_cycle.started_issues, - "unstarted_issues": old_cycle.unstarted_issues, - "backlog_issues": old_cycle.backlog_issues, - "distribution": { - "labels": label_distribution_data, - "assignees": assignee_distribution_data, - "completion_chart": completion_chart, - }, - "estimate_distribution": ( - {} - if not estimate_type - else { - "labels": label_estimate_distribution, - "assignees": assignee_estimate_distribution, - "completion_chart": estimate_completion_chart, - } - ), - } - current_cycle.save(update_fields=["progress_snapshot"]) - - if new_cycle.end_date is not None and new_cycle.end_date < timezone.now(): + # transfer work items only when cycle is completed (passed the end data) + if old_cycle.end_date is not None and old_cycle.end_date > timezone.now(): return Response( - {"error": "The cycle where the issues are transferred is already completed"}, + {"error": "The old cycle is not completed yet"}, status=status.HTTP_400_BAD_REQUEST, ) - cycle_issues = CycleIssue.objects.filter( - cycle_id=cycle_id, + # Call the utility function to handle the transfer + result = transfer_cycle_issues( + slug=slug, project_id=project_id, - workspace__slug=slug, - issue__state__group__in=["backlog", "unstarted", "started"], + cycle_id=cycle_id, + new_cycle_id=new_cycle_id, + request=request, + user_id=self.request.user.id, ) - updated_cycles = [] - update_cycle_issue_activity = [] - for cycle_issue in cycle_issues: - cycle_issue.cycle_id = new_cycle_id - updated_cycles.append(cycle_issue) - update_cycle_issue_activity.append( - { - "old_cycle_id": str(cycle_id), - "new_cycle_id": str(new_cycle_id), - "issue_id": str(cycle_issue.issue_id), - } + # Handle the result + if result.get("success"): + return Response({"message": "Success"}, status=status.HTTP_200_OK) + else: + return Response( + {"error": result.get("error")}, + status=status.HTTP_400_BAD_REQUEST, ) - - cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100) - - # Capture Issue Activity - issue_activity.delay( - type="cycle.activity.created", - requested_data=json.dumps({"cycles_list": []}), - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "updated_cycle_issues": update_cycle_issue_activity, - "created_cycle_issues": "[]", - } - ), - epoch=int(timezone.now().timestamp()), - notification=True, - origin=base_host(request=request, is_app=True), - ) - - return Response({"message": "Success"}, status=status.HTTP_200_OK) diff --git a/apps/api/plane/api/views/invite.py b/apps/api/plane/api/views/invite.py new file mode 100644 index 0000000000..f1263b0090 --- /dev/null +++ b/apps/api/plane/api/views/invite.py @@ -0,0 +1,150 @@ +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from drf_spectacular.utils import ( + extend_schema, + OpenApiResponse, + OpenApiRequest, + OpenApiParameter, + OpenApiTypes, +) + +# Module imports +from plane.api.views.base import BaseViewSet +from plane.db.models import WorkspaceMemberInvite, Workspace +from plane.api.serializers import WorkspaceInviteSerializer +from plane.utils.permissions import WorkspaceOwnerPermission +from plane.utils.openapi.parameters import WORKSPACE_SLUG_PARAMETER + + +class WorkspaceInvitationsViewset(BaseViewSet): + """ + Endpoint for creating, listing and deleting workspace invites. + """ + + serializer_class = WorkspaceInviteSerializer + model = WorkspaceMemberInvite + + permission_classes = [ + WorkspaceOwnerPermission, + ] + + def get_queryset(self): + return self.filter_queryset(super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))) + + def get_object(self): + return self.get_queryset().get(pk=self.kwargs.get("pk")) + + @extend_schema( + summary="List workspace invites", + description="List all workspace invites for a workspace", + responses={ + 200: OpenApiResponse( + description="Workspace invites", + response=WorkspaceInviteSerializer(many=True), + ) + }, + parameters=[ + WORKSPACE_SLUG_PARAMETER, + ], + ) + def list(self, request, slug): + workspace_member_invites = self.get_queryset() + serializer = WorkspaceInviteSerializer(workspace_member_invites, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Get workspace invite", + description="Get a workspace invite by ID", + responses={200: OpenApiResponse(description="Workspace invite", response=WorkspaceInviteSerializer)}, + parameters=[ + WORKSPACE_SLUG_PARAMETER, + OpenApiParameter( + name="pk", + description="Workspace invite ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + ), + ], + ) + def retrieve(self, request, slug, pk): + workspace_member_invite = self.get_object() + serializer = WorkspaceInviteSerializer(workspace_member_invite) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Create workspace invite", + description="Create a workspace invite", + responses={201: OpenApiResponse(description="Workspace invite", response=WorkspaceInviteSerializer)}, + request=OpenApiRequest(request=WorkspaceInviteSerializer), + parameters=[ + WORKSPACE_SLUG_PARAMETER, + ], + ) + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + serializer = WorkspaceInviteSerializer(data=request.data, context={"slug": slug}) + serializer.is_valid(raise_exception=True) + serializer.save(workspace=workspace, created_by=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @extend_schema( + summary="Update workspace invite", + description="Update a workspace invite", + responses={200: OpenApiResponse(description="Workspace invite", response=WorkspaceInviteSerializer)}, + request=OpenApiRequest(request=WorkspaceInviteSerializer), + parameters=[ + WORKSPACE_SLUG_PARAMETER, + OpenApiParameter( + name="pk", + description="Workspace invite ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + ), + ], + ) + def partial_update(self, request, slug, pk): + workspace_member_invite = self.get_object() + if request.data.get("email"): + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={"error": "Email cannot be updated after invite is created.", "code": "EMAIL_CANNOT_BE_UPDATED"}, + ) + serializer = WorkspaceInviteSerializer( + workspace_member_invite, data=request.data, partial=True, context={"slug": slug} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Delete workspace invite", + description="Delete a workspace invite", + responses={204: OpenApiResponse(description="Workspace invite deleted")}, + parameters=[ + WORKSPACE_SLUG_PARAMETER, + OpenApiParameter( + name="pk", + description="Workspace invite ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + ), + ], + ) + def destroy(self, request, slug, pk): + workspace_member_invite = self.get_object() + if workspace_member_invite.accepted: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={"error": "Invite already accepted", "code": "INVITE_ALREADY_ACCEPTED"}, + ) + if workspace_member_invite.responded_at: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={"error": "Invite already responded", "code": "INVITE_ALREADY_RESPONDED"}, + ) + workspace_member_invite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index d3686ceea5..fe32fe3fdd 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -987,12 +987,12 @@ class LabelDetailAPIEndpoint(LabelListCreateAPIEndpoint): serializer = LabelCreateUpdateSerializer(label, data=request.data, partial=True) if serializer.is_valid(): if ( - str(request.data.get("external_id")) - and (label.external_id != str(request.data.get("external_id"))) + request.data.get("external_id") + and request.data.get("external_source") and Label.objects.filter( project_id=project_id, workspace__slug=slug, - external_source=request.data.get("external_source", label.external_source), + external_source=request.data.get("external_source"), external_id=request.data.get("external_id"), ) .exclude(id=pk) @@ -1695,23 +1695,27 @@ class IssueActivityDetailAPIEndpoint(BaseAPIView): Retrieve details of a specific activity. Excludes comment, vote, reaction, and draft activities. """ - issue_activities = ( - IssueActivity.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id) - .filter( - ~Q(field__in=["comment", "vote", "reaction", "draft"]), - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, + issue_activity = ( + ( + IssueActivity.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id, id=pk) + .filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("actor", "workspace", "issue", "project") ) - .filter(project__archived_at__isnull=True) - .select_related("actor", "workspace", "issue", "project") - ).order_by(request.GET.get("order_by", "created_at")) + .order_by(request.GET.get("order_by", "created_at")) + .first() + ) - return self.paginate( - request=request, - queryset=(issue_activities), - on_results=lambda issue_activity: IssueActivitySerializer( - issue_activity, many=True, fields=self.fields, expand=self.expand - ).data, + if not issue_activity: + return Response({"message": "Activity not found.", "code": "NOT_FOUND"}, status=status.HTTP_404_NOT_FOUND) + + return Response( + IssueActivitySerializer(issue_activity, fields=self.fields, expand=self.expand).data, + status=status.HTTP_200_OK, ) @@ -1804,7 +1808,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView): request.user.id, project_id=project_id, issue=issue, - allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value], + allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value], allow_creator=True, ): return Response( @@ -1957,10 +1961,10 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id) # if the request user is creator or admin then delete the attachment if not user_has_issue_permission( - request.user, + request.user.id, project_id=project_id, issue=issue, - allowed_roles=[ROLE.ADMIN.value], + allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value], allow_creator=True, ): return Response( @@ -2030,7 +2034,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): """ # if the user is part of the project then allow the download if not user_has_issue_permission( - request.user, + request.user.id, project_id=project_id, issue=None, allowed_roles=None, @@ -2095,10 +2099,10 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id) # if the user is creator or admin then allow the upload if not user_has_issue_permission( - request.user, + request.user.id, project_id=project_id, issue=issue, - allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value], + allowed_roles=[ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value], allow_creator=True, ): return Response( diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index f761d5c91e..854bc7ae67 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -4,13 +4,14 @@ from rest_framework import status from drf_spectacular.utils import ( extend_schema, OpenApiResponse, + OpenApiRequest, ) # Module imports from .base import BaseAPIView -from plane.api.serializers import UserLiteSerializer +from plane.api.serializers import UserLiteSerializer, ProjectMemberSerializer from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember -from plane.app.permissions import ProjectMemberPermission, WorkSpaceAdminPermission +from plane.utils.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission from plane.utils.openapi import ( WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER, @@ -86,11 +87,15 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView): return Response(users_with_roles, status=status.HTTP_200_OK) -# API endpoint to get and insert users inside the workspace -class ProjectMemberAPIEndpoint(BaseAPIView): +class ProjectMemberListCreateAPIEndpoint(BaseAPIView): permission_classes = [ProjectMemberPermission] use_read_replica = True + def get_permissions(self): + if self.request.method == "GET": + return [ProjectMemberPermission()] + return [ProjectAdminPermission()] + @extend_schema( operation_id="get_project_members", summary="List project members", @@ -129,5 +134,86 @@ class ProjectMemberAPIEndpoint(BaseAPIView): # Get all the users that are present inside the workspace users = UserLiteSerializer(User.objects.filter(id__in=project_members), many=True).data - return Response(users, status=status.HTTP_200_OK) + + @extend_schema( + operation_id="create_project_member", + summary="Create project member", + description="Create a new project member", + tags=["Members"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={201: OpenApiResponse(description="Project member created", response=ProjectMemberSerializer)}, + request=OpenApiRequest(request=ProjectMemberSerializer), + ) + def post(self, request, slug, project_id): + serializer = ProjectMemberSerializer(data=request.data, context={"slug": slug}) + serializer.is_valid(raise_exception=True) + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +# API endpoint to get and update a project member +class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint): + + @extend_schema( + operation_id="get_project_member", + summary="Get project member", + description="Retrieve a project member by ID.", + tags=["Members"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={ + 200: OpenApiResponse(description="Project member", response=ProjectMemberSerializer), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + # Get a project member by ID + def get(self, request, slug, project_id, pk): + """Get project member + + Retrieve a project member by ID. + Returns a project member with their project-specific roles and access levels. + """ + # Check if the workspace exists + if not Workspace.objects.filter(slug=slug).exists(): + return Response( + {"error": "Provided workspace does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the workspace members that are present inside the workspace + project_members = ProjectMember.objects.get(project_id=project_id, workspace__slug=slug, pk=pk) + user = User.objects.get(id=project_members.member_id) + user = UserLiteSerializer(user).data + return Response(user, status=status.HTTP_200_OK) + + @extend_schema( + operation_id="update_project_member", + summary="Update project member", + description="Update a project member", + tags=["Members"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={200: OpenApiResponse(description="Project member updated", response=ProjectMemberSerializer)}, + request=OpenApiRequest(request=ProjectMemberSerializer), + ) + def patch(self, request, slug, project_id, pk): + project_member = ProjectMember.objects.get(project_id=project_id, workspace__slug=slug, pk=pk) + serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True, context={"slug": slug}) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + operation_id="delete_project_member", + summary="Delete project member", + description="Delete a project member", + tags=["Members"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={204: OpenApiResponse(description="Project member deleted")}, + ) + def delete(self, request, slug, project_id, pk): + project_member = ProjectMember.objects.get(project_id=project_id, workspace__slug=slug, pk=pk) + project_member.is_active = False + project_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/api/views/module.py b/apps/api/plane/api/views/module.py index d79b940848..7e47c99eb2 100644 --- a/apps/api/plane/api/views/module.py +++ b/apps/api/plane/api/views/module.py @@ -871,6 +871,8 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView): module_id=module_id, issue_id=issue_id, ) + + module_name = module_issue.module.name if module_issue.module is not None else "" module_issue.delete() issue_activity.delay( type="module.activity.deleted", @@ -878,7 +880,7 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView): actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), - current_instance=None, + current_instance=json.dumps({"module_name": module_name}), epoch=int(timezone.now().timestamp()), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -1022,7 +1024,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): ], request={}, responses={ - 204: ARCHIVED_RESPONSE, + 204: None, 400: CANNOT_ARCHIVE_RESPONSE, 404: MODULE_NOT_FOUND_RESPONSE, }, @@ -1057,7 +1059,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): MODULE_PK_PARAMETER, ], responses={ - 204: UNARCHIVED_RESPONSE, + 204: None, 404: MODULE_NOT_FOUND_RESPONSE, }, ) diff --git a/apps/api/plane/api/views/state.py b/apps/api/plane/api/views/state.py index bd91de39aa..8d2633e675 100644 --- a/apps/api/plane/api/views/state.py +++ b/apps/api/plane/api/views/state.py @@ -233,7 +233,7 @@ class StateDetailAPIEndpoint(BaseAPIView): ) # Check for any issues in the state - issue_exist = Issue.issue_objects.filter(state=state_id).exists() + issue_exist = Issue.objects.filter(state=state_id).exists() if issue_exist: return Response( diff --git a/apps/api/plane/app/permissions/__init__.py b/apps/api/plane/app/permissions/__init__.py index 95ee038e18..849f7ba3ee 100644 --- a/apps/api/plane/app/permissions/__init__.py +++ b/apps/api/plane/app/permissions/__init__.py @@ -11,6 +11,7 @@ from .project import ( ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission, + ProjectAdminPermission, ) from .base import allow_permission, ROLE from .page import ProjectPagePermission diff --git a/apps/api/plane/app/permissions/project.py b/apps/api/plane/app/permissions/project.py index e095ffed48..a8c0f92a27 100644 --- a/apps/api/plane/app/permissions/project.py +++ b/apps/api/plane/app/permissions/project.py @@ -112,6 +112,20 @@ class ProjectEntityPermission(BasePermission): ).exists() +class ProjectAdminPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role=ROLE.ADMIN.value, + project_id=view.project_id, + is_active=True, + ).exists() + + class ProjectLitePermission(BasePermission): def has_permission(self, request, view): if request.user.is_anonymous: diff --git a/apps/api/plane/app/urls/__init__.py b/apps/api/plane/app/urls/__init__.py index 8cfc18dbb6..3feab4cb54 100644 --- a/apps/api/plane/app/urls/__init__.py +++ b/apps/api/plane/app/urls/__init__.py @@ -17,6 +17,7 @@ from .views import urlpatterns as view_urls from .webhook import urlpatterns as webhook_urls from .workspace import urlpatterns as workspace_urls from .timezone import urlpatterns as timezone_urls +from .exporter import urlpatterns as exporter_urls urlpatterns = [ *analytic_urls, @@ -38,4 +39,5 @@ urlpatterns = [ *api_urls, *webhook_urls, *timezone_urls, + *exporter_urls, ] diff --git a/apps/api/plane/app/urls/exporter.py b/apps/api/plane/app/urls/exporter.py new file mode 100644 index 0000000000..0bcb4621b2 --- /dev/null +++ b/apps/api/plane/app/urls/exporter.py @@ -0,0 +1,12 @@ +from django.urls import path + +from plane.app.views import ExportIssuesEndpoint + + +urlpatterns = [ + path( + "workspaces//export-issues/", + ExportIssuesEndpoint.as_view(), + name="export-issues", + ), +] \ No newline at end of file diff --git a/apps/api/plane/app/urls/issue.py b/apps/api/plane/app/urls/issue.py index 0521a33dc0..1d809e248f 100644 --- a/apps/api/plane/app/urls/issue.py +++ b/apps/api/plane/app/urls/issue.py @@ -7,7 +7,6 @@ from plane.app.views import ( IssueLinkViewSet, IssueAttachmentEndpoint, CommentReactionViewSet, - ExportIssuesEndpoint, IssueActivityEndpoint, IssueArchiveViewSet, IssueCommentViewSet, @@ -141,12 +140,6 @@ urlpatterns = [ IssueAttachmentV2Endpoint.as_view(), name="project-issue-attachments", ), - ## Export Issues - path( - "workspaces//export-issues/", - ExportIssuesEndpoint.as_view(), - name="export-issues", - ), ## End Issues ## Issue Activity path( diff --git a/apps/api/plane/app/views/cycle/base.py b/apps/api/plane/app/views/cycle/base.py index 711d6724ac..712d71754e 100644 --- a/apps/api/plane/app/views/cycle/base.py +++ b/apps/api/plane/app/views/cycle/base.py @@ -51,6 +51,7 @@ from plane.db.models import ( from plane.utils.analytics_plot import burndown_plot from plane.bgtasks.recent_visited_task import recent_visited_task from plane.utils.host import base_host +from plane.utils.cycle_transfer_issues import transfer_cycle_issues from .. import BaseAPIView, BaseViewSet from plane.bgtasks.webhook_task import model_activity from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter @@ -96,7 +97,9 @@ class CycleViewSet(BaseViewSet): .prefetch_related( Prefetch( "issue_cycle__issue__assignees", - queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(), + queryset=User.objects.only( + "avatar_asset", "first_name", "id" + ).distinct(), ) ) .prefetch_related( @@ -147,7 +150,8 @@ class CycleViewSet(BaseViewSet): .annotate( status=Case( When( - Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc), + Q(start_date__lte=current_time_in_utc) + & Q(end_date__gte=current_time_in_utc), then=Value("CURRENT"), ), When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")), @@ -166,7 +170,11 @@ class CycleViewSet(BaseViewSet): "issue_cycle__issue__assignees__id", distinct=True, filter=~Q(issue_cycle__issue__assignees__id__isnull=True) - & (Q(issue_cycle__issue__issue_assignee__deleted_at__isnull=True)), + & ( + Q( + issue_cycle__issue__issue_assignee__deleted_at__isnull=True + ) + ), ), Value([], output_field=ArrayField(UUIDField())), ) @@ -197,7 +205,9 @@ class CycleViewSet(BaseViewSet): # Current Cycle if cycle_view == "current": - queryset = queryset.filter(start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc) + queryset = queryset.filter( + start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc + ) data = queryset.values( # necessary fields @@ -264,10 +274,16 @@ class CycleViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): - if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or ( - request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None + if ( + request.data.get("start_date", None) is None + and request.data.get("end_date", None) is None + ) or ( + request.data.get("start_date", None) is not None + and request.data.get("end_date", None) is not None ): - serializer = CycleWriteSerializer(data=request.data, context={"project_id": project_id}) + serializer = CycleWriteSerializer( + data=request.data, context={"project_id": project_id} + ) if serializer.is_valid(): serializer.save(project_id=project_id, owned_by=request.user) cycle = ( @@ -307,7 +323,9 @@ class CycleViewSet(BaseViewSet): project_timezone = project.timezone datetime_fields = ["start_date", "end_date"] - cycle = user_timezone_converter(cycle, datetime_fields, project_timezone) + cycle = user_timezone_converter( + cycle, datetime_fields, project_timezone + ) # Send the model activity model_activity.delay( @@ -323,13 +341,17 @@ class CycleViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: return Response( - {"error": "Both start date and end date are either required or are to be null"}, + { + "error": "Both start date and end date are either required or are to be null" + }, status=status.HTTP_400_BAD_REQUEST, ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def partial_update(self, request, slug, project_id, pk): - queryset = self.get_queryset().filter(workspace__slug=slug, project_id=project_id, pk=pk) + queryset = self.get_queryset().filter( + workspace__slug=slug, project_id=project_id, pk=pk + ) cycle = queryset.first() if cycle.archived_at: return Response( @@ -337,21 +359,29 @@ class CycleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder) + current_instance = json.dumps( + CycleSerializer(cycle).data, cls=DjangoJSONEncoder + ) request_data = request.data if cycle.end_date is not None and cycle.end_date < timezone.now(): if "sort_order" in request_data: # Can only change sort order for a completed cycle`` - request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)} + request_data = { + "sort_order": request_data.get("sort_order", cycle.sort_order) + } else: return Response( - {"error": "The Cycle has already been completed so it cannot be edited"}, + { + "error": "The Cycle has already been completed so it cannot be edited" + }, status=status.HTTP_400_BAD_REQUEST, ) - serializer = CycleWriteSerializer(cycle, data=request.data, partial=True, context={"project_id": project_id}) + serializer = CycleWriteSerializer( + cycle, data=request.data, partial=True, context={"project_id": project_id} + ) if serializer.is_valid(): serializer.save() cycle = queryset.values( @@ -451,7 +481,9 @@ class CycleViewSet(BaseViewSet): ) if data is None: - return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND) + return Response( + {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND + ) queryset = queryset.first() # Fetch the project timezone @@ -473,7 +505,11 @@ class CycleViewSet(BaseViewSet): def destroy(self, request, slug, project_id, pk): cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True)) + cycle_issues = list( + CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( + "issue", flat=True + ) + ) issue_activity.delay( type="cycle.activity.deleted", @@ -524,7 +560,9 @@ class CycleDateCheckEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - start_date = convert_to_utc(date=str(start_date), project_id=project_id, is_start_date=True) + start_date = convert_to_utc( + date=str(start_date), project_id=project_id, is_start_date=True + ) end_date = convert_to_utc( date=str(end_date), project_id=project_id, @@ -597,409 +635,23 @@ class TransferCycleIssueEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first() - - old_cycle = ( - Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id) - .annotate( - total_issues=Count( - "issue_cycle", - filter=Q( - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__deleted_at__isnull=True, - issue_cycle__issue__deleted_at__isnull=True, - ), - ) - ) - .annotate( - completed_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__issue__deleted_at__isnull=True, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .annotate( - cancelled_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="cancelled", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__issue__deleted_at__isnull=True, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .annotate( - started_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__issue__deleted_at__isnull=True, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .annotate( - unstarted_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="unstarted", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__issue__deleted_at__isnull=True, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .annotate( - backlog_issues=Count( - "issue_cycle__issue__state__group", - filter=Q( - issue_cycle__issue__state__group="backlog", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__issue__deleted_at__isnull=True, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - ) - old_cycle = old_cycle.first() - - estimate_type = Project.objects.filter( - workspace__slug=slug, - pk=project_id, - estimate__isnull=False, - estimate__type="points", - ).exists() - - if estimate_type: - assignee_estimate_data = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(display_name=F("assignees__display_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate( - avatar_url=Case( - # If `avatar_asset` exists, use it to generate the asset URL - When( - assignees__avatar_asset__isnull=False, - then=Concat( - Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field - Value("/"), - ), - ), - # If `avatar_asset` is None, fall back to using `avatar` field directly - When( - assignees__avatar_asset__isnull=True, - then="assignees__avatar", - ), - default=Value(None), - output_field=models.CharField(), - ) - ) - .values("display_name", "assignee_id", "avatar_url") - .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) - .annotate( - completed_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("display_name") - ) - # assignee distribution serialization - assignee_estimate_distribution = [ - { - "display_name": item["display_name"], - "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None), - "avatar": item.get("avatar"), - "avatar_url": item.get("avatar_url"), - "total_estimates": item["total_estimates"], - "completed_estimates": item["completed_estimates"], - "pending_estimates": item["pending_estimates"], - } - for item in assignee_estimate_data - ] - - label_distribution_data = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) - .annotate( - completed_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - - estimate_completion_chart = burndown_plot( - queryset=old_cycle, - slug=slug, - project_id=project_id, - plot_type="points", - cycle_id=cycle_id, - ) - # Label distribution serialization - label_estimate_distribution = [ - { - "label_name": item["label_name"], - "color": item["color"], - "label_id": (str(item["label_id"]) if item["label_id"] else None), - "total_estimates": item["total_estimates"], - "completed_estimates": item["completed_estimates"], - "pending_estimates": item["pending_estimates"], - } - for item in label_distribution_data - ] - - # Get the assignee distribution - assignee_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(display_name=F("assignees__display_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate( - avatar_url=Case( - # If `avatar_asset` exists, use it to generate the asset URL - When( - assignees__avatar_asset__isnull=False, - then=Concat( - Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field - Value("/"), - ), - ), - # If `avatar_asset` is None, fall back to using `avatar` field directly - When(assignees__avatar_asset__isnull=True, then="assignees__avatar"), - default=Value(None), - output_field=models.CharField(), - ) - ) - .values("display_name", "assignee_id", "avatar_url") - .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) - .annotate( - completed_issues=Count( - "id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("display_name") - ) - # assignee distribution serialized - assignee_distribution_data = [ - { - "display_name": item["display_name"], - "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None), - "avatar": item.get("avatar"), - "avatar_url": item.get("avatar_url"), - "total_issues": item["total_issues"], - "completed_issues": item["completed_issues"], - "pending_issues": item["pending_issues"], - } - for item in assignee_distribution - ] - - # Get the label distribution - label_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) - .annotate( - completed_issues=Count( - "id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - - # Label distribution serilization - label_distribution_data = [ - { - "label_name": item["label_name"], - "color": item["color"], - "label_id": (str(item["label_id"]) if item["label_id"] else None), - "total_issues": item["total_issues"], - "completed_issues": item["completed_issues"], - "pending_issues": item["pending_issues"], - } - for item in label_distribution - ] - - # Pass the new_cycle queryset to burndown_plot - completion_chart = burndown_plot( - queryset=old_cycle, + # Transfer cycle issues and create progress snapshot + result = transfer_cycle_issues( slug=slug, project_id=project_id, - plot_type="issues", cycle_id=cycle_id, + new_cycle_id=new_cycle_id, + request=request, + user_id=request.user.id, ) - current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first() - - current_cycle.progress_snapshot = { - "total_issues": old_cycle.total_issues, - "completed_issues": old_cycle.completed_issues, - "cancelled_issues": old_cycle.cancelled_issues, - "started_issues": old_cycle.started_issues, - "unstarted_issues": old_cycle.unstarted_issues, - "backlog_issues": old_cycle.backlog_issues, - "distribution": { - "labels": label_distribution_data, - "assignees": assignee_distribution_data, - "completion_chart": completion_chart, - }, - "estimate_distribution": ( - {} - if not estimate_type - else { - "labels": label_estimate_distribution, - "assignees": assignee_estimate_distribution, - "completion_chart": estimate_completion_chart, - } - ), - } - current_cycle.save(update_fields=["progress_snapshot"]) - - if new_cycle.end_date is not None and new_cycle.end_date < timezone.now(): + # Handle error response + if result.get("error"): return Response( - {"error": "The cycle where the issues are transferred is already completed"}, + {"error": result["error"]}, status=status.HTTP_400_BAD_REQUEST, ) - cycle_issues = CycleIssue.objects.filter( - cycle_id=cycle_id, - project_id=project_id, - workspace__slug=slug, - issue__state__group__in=["backlog", "unstarted", "started"], - ) - - updated_cycles = [] - update_cycle_issue_activity = [] - for cycle_issue in cycle_issues: - cycle_issue.cycle_id = new_cycle_id - updated_cycles.append(cycle_issue) - update_cycle_issue_activity.append( - { - "old_cycle_id": str(cycle_id), - "new_cycle_id": str(new_cycle_id), - "issue_id": str(cycle_issue.issue_id), - } - ) - - cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100) - - # Capture Issue Activity - issue_activity.delay( - type="cycle.activity.created", - requested_data=json.dumps({"cycles_list": []}), - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "updated_cycle_issues": update_cycle_issue_activity, - "created_cycle_issues": "[]", - } - ), - epoch=int(timezone.now().timestamp()), - notification=True, - origin=base_host(request=request, is_app=True), - ) - return Response({"message": "Success"}, status=status.HTTP_200_OK) @@ -1014,8 +666,12 @@ class CycleUserPropertiesEndpoint(BaseAPIView): ) cycle_properties.filters = request.data.get("filters", cycle_properties.filters) - cycle_properties.rich_filters = request.data.get("rich_filters", cycle_properties.rich_filters) - cycle_properties.display_filters = request.data.get("display_filters", cycle_properties.display_filters) + cycle_properties.rich_filters = request.data.get( + "rich_filters", cycle_properties.rich_filters + ) + cycle_properties.display_filters = request.data.get( + "display_filters", cycle_properties.display_filters + ) cycle_properties.display_properties = request.data.get( "display_properties", cycle_properties.display_properties ) @@ -1039,9 +695,13 @@ class CycleUserPropertiesEndpoint(BaseAPIView): class CycleProgressEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, cycle_id): - cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id).first() + cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, id=cycle_id + ).first() if not cycle: - return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND) + return Response( + {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND + ) aggregate_estimates = ( Issue.issue_objects.filter( estimate_point__estimate__type="points", @@ -1087,7 +747,9 @@ class CycleProgressEndpoint(BaseAPIView): output_field=FloatField(), ) ), - total_estimate_points=Sum("value_as_float", default=Value(0), output_field=FloatField()), + total_estimate_points=Sum( + "value_as_float", default=Value(0), output_field=FloatField() + ), ) ) if cycle.progress_snapshot: @@ -1147,11 +809,22 @@ class CycleProgressEndpoint(BaseAPIView): return Response( { - "backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0, - "unstarted_estimate_points": aggregate_estimates["unstarted_estimate_point"] or 0, - "started_estimate_points": aggregate_estimates["started_estimate_point"] or 0, - "cancelled_estimate_points": aggregate_estimates["cancelled_estimate_point"] or 0, - "completed_estimate_points": aggregate_estimates["completed_estimate_points"] or 0, + "backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] + or 0, + "unstarted_estimate_points": aggregate_estimates[ + "unstarted_estimate_point" + ] + or 0, + "started_estimate_points": aggregate_estimates["started_estimate_point"] + or 0, + "cancelled_estimate_points": aggregate_estimates[ + "cancelled_estimate_point" + ] + or 0, + "completed_estimate_points": aggregate_estimates[ + "completed_estimate_points" + ] + or 0, "total_estimate_points": aggregate_estimates["total_estimate_points"], "backlog_issues": backlog_issues, "total_issues": total_issues, @@ -1169,7 +842,9 @@ class CycleAnalyticsEndpoint(BaseAPIView): def get(self, request, slug, project_id, cycle_id): analytic_type = request.GET.get("type", "issues") cycle = ( - Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id) + Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, id=cycle_id + ) .annotate( total_issues=Count( "issue_cycle__issue__id", @@ -1252,7 +927,9 @@ class CycleAnalyticsEndpoint(BaseAPIView): ) ) .values("display_name", "assignee_id", "avatar_url") - .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) + .annotate( + total_estimates=Sum(Cast("estimate_point__value", FloatField())) + ) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -1287,7 +964,9 @@ class CycleAnalyticsEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) + .annotate( + total_estimates=Sum(Cast("estimate_point__value", FloatField())) + ) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -1389,7 +1068,11 @@ class CycleAnalyticsEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate(total_issues=Count("label_id", filter=Q(archived_at__isnull=True, is_draft=False))) + .annotate( + total_issues=Count( + "label_id", filter=Q(archived_at__isnull=True, is_draft=False) + ) + ) .annotate( completed_issues=Count( "label_id", diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py index 72fb4ef8ee..b8946d22a6 100644 --- a/apps/api/plane/app/views/page/base.py +++ b/apps/api/plane/app/views/page/base.py @@ -137,7 +137,11 @@ class PageViewSet(BaseViewSet): if serializer.is_valid(): serializer.save() # capture the page transaction - page_transaction.delay(request.data, None, serializer.data["id"]) + page_transaction.delay( + new_description_html=request.data.get("description_html", "

"), + old_description_html=None, + page_id=serializer.data["id"], + ) page = self.get_queryset().get(pk=serializer.data["id"]) serializer = PageDetailSerializer(page) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -168,11 +172,8 @@ class PageViewSet(BaseViewSet): # capture the page transaction if request.data.get("description_html"): page_transaction.delay( - new_value=request.data, - old_value=json.dumps( - {"description_html": page_description}, - cls=DjangoJSONEncoder, - ), + new_description_html=request.data.get("description_html", "

"), + old_description_html=page_description, page_id=page_id, ) @@ -504,7 +505,11 @@ class PagesDescriptionViewSet(BaseViewSet): if serializer.is_valid(): # Capture the page transaction if request.data.get("description_html"): - page_transaction.delay(new_value=request.data, old_value=existing_instance, page_id=page_id) + page_transaction.delay( + new_description_html=request.data.get("description_html", "

"), + old_description_html=page.description_html, + page_id=page_id, + ) # Update the page using serializer updated_page = serializer.save() @@ -550,7 +555,11 @@ class PageDuplicateEndpoint(BaseAPIView): updated_by_id=page.updated_by_id, ) - page_transaction.delay({"description_html": page.description_html}, None, page.id) + page_transaction.delay( + new_description_html=page.description_html, + old_description_html=None, + page_id=page.id, + ) # Copy the s3 objects uploaded in the page copy_s3_objects_of_description_and_assets.delay( diff --git a/apps/api/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py index a598d1ee19..5309bff554 100644 --- a/apps/api/plane/app/views/search/base.py +++ b/apps/api/plane/app/views/search/base.py @@ -43,22 +43,25 @@ class GlobalSearchEndpoint(BaseAPIView): also show related workspace if found """ - def filter_workspaces(self, query, slug, project_id, workspace_search): + def filter_workspaces(self, query, _slug, _project_id, _workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) return ( Workspace.objects.filter(q, workspace_member__member=self.request.user) + .order_by("-created_at") .distinct() .values("name", "id", "slug") ) - def filter_projects(self, query, slug, project_id, workspace_search): + def filter_projects(self, query, slug, _project_id, _workspace_search): fields = ["name", "identifier"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) return ( Project.objects.filter( q, @@ -67,6 +70,7 @@ class GlobalSearchEndpoint(BaseAPIView): archived_at__isnull=True, workspace__slug=slug, ) + .order_by("-created_at") .distinct() .values("name", "id", "identifier", "workspace__slug") ) @@ -74,14 +78,15 @@ class GlobalSearchEndpoint(BaseAPIView): def filter_issues(self, query, slug, project_id, workspace_search): fields = ["name", "sequence_id", "project__identifier"] q = Q() - for field in fields: - if field == "sequence_id": - # Match whole integers only (exclude decimal numbers) - sequences = re.findall(r"\b\d+\b", query) - for sequence_id in sequences: - q |= Q(**{"sequence_id": sequence_id}) - else: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) issues = Issue.issue_objects.filter( q, @@ -106,8 +111,9 @@ class GlobalSearchEndpoint(BaseAPIView): def filter_cycles(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) cycles = Cycle.objects.filter( q, @@ -120,13 +126,20 @@ class GlobalSearchEndpoint(BaseAPIView): if workspace_search == "false" and project_id: cycles = cycles.filter(project_id=project_id) - return cycles.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") + return ( + cycles.order_by("-created_at") + .distinct() + .values( + "name", "id", "project_id", "project__identifier", "workspace__slug" + ) + ) def filter_modules(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) modules = Module.objects.filter( q, @@ -139,13 +152,20 @@ class GlobalSearchEndpoint(BaseAPIView): if workspace_search == "false" and project_id: modules = modules.filter(project_id=project_id) - return modules.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") + return ( + modules.order_by("-created_at") + .distinct() + .values( + "name", "id", "project_id", "project__identifier", "workspace__slug" + ) + ) def filter_pages(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) pages = ( Page.objects.filter( @@ -157,7 +177,9 @@ class GlobalSearchEndpoint(BaseAPIView): ) .annotate( project_ids=Coalesce( - ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)), + ArrayAgg( + "projects__id", distinct=True, filter=~Q(projects__id=True) + ), Value([], output_field=ArrayField(UUIDField())), ) ) @@ -174,19 +196,28 @@ class GlobalSearchEndpoint(BaseAPIView): ) if workspace_search == "false" and project_id: - project_subquery = ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=project_id).values_list( - "project_id", flat=True - )[:1] + project_subquery = ProjectPage.objects.filter( + page_id=OuterRef("id"), project_id=project_id + ).values_list("project_id", flat=True)[:1] - pages = pages.annotate(project_id=Subquery(project_subquery)).filter(project_id=project_id) + pages = pages.annotate(project_id=Subquery(project_subquery)).filter( + project_id=project_id + ) - return pages.distinct().values("name", "id", "project_ids", "project_identifiers", "workspace__slug") + return ( + pages.order_by("-created_at") + .distinct() + .values( + "name", "id", "project_ids", "project_identifiers", "workspace__slug" + ) + ) def filter_views(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) issue_views = IssueView.objects.filter( q, @@ -199,29 +230,57 @@ class GlobalSearchEndpoint(BaseAPIView): if workspace_search == "false" and project_id: issue_views = issue_views.filter(project_id=project_id) - return issue_views.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") + return ( + issue_views.order_by("-created_at") + .distinct() + .values( + "name", "id", "project_id", "project__identifier", "workspace__slug" + ) + ) + + def filter_intakes(self, query, slug, project_id, workspace_search): + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + if query: + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = Issue.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + workspace__slug=slug, + ).filter(models.Q(issue_intake__status=0) | models.Q(issue_intake__status=-2)) + + if workspace_search == "false" and project_id: + issues = issues.filter(project_id=project_id) + + return ( + issues.order_by("-created_at") + .distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "workspace__slug", + )[:100] + ) def get(self, request, slug): query = request.query_params.get("search", False) + entities_param = request.query_params.get("entities") workspace_search = request.query_params.get("workspace_search", "false") project_id = request.query_params.get("project_id", False) - if not query: - return Response( - { - "results": { - "workspace": [], - "project": [], - "issue": [], - "cycle": [], - "module": [], - "issue_view": [], - "page": [], - } - }, - status=status.HTTP_200_OK, - ) - MODELS_MAPPER = { "workspace": self.filter_workspaces, "project": self.filter_projects, @@ -230,13 +289,27 @@ class GlobalSearchEndpoint(BaseAPIView): "module": self.filter_modules, "issue_view": self.filter_views, "page": self.filter_pages, + "intake": self.filter_intakes, } + # Determine which entities to search + if entities_param: + requested_entities = [ + e.strip() for e in entities_param.split(",") if e.strip() + ] + requested_entities = [e for e in requested_entities if e in MODELS_MAPPER] + else: + requested_entities = list(MODELS_MAPPER.keys()) + results = {} - for model in MODELS_MAPPER.keys(): - func = MODELS_MAPPER.get(model, None) - results[model] = func(query, slug, project_id, workspace_search) + for entity in requested_entities: + func = MODELS_MAPPER.get(entity) + if func: + results[entity] = func( + query or None, slug, project_id, workspace_search + ) + return Response({"results": results}, status=status.HTTP_200_OK) @@ -294,29 +367,15 @@ class SearchEndpoint(BaseAPIView): .order_by("-created_at") ) - if issue_id: - issue_created_by = ( - Issue.objects.filter(id=issue_id).values_list("created_by_id", flat=True).first() - ) - users = ( - users.filter(Q(role__gt=10) | Q(member_id=issue_created_by)) - .distinct() - .values( - "member__avatar_url", - "member__display_name", - "member__id", - ) - ) - else: - users = ( - users.filter(Q(role__gt=10)) - .distinct() - .values( - "member__avatar_url", - "member__display_name", - "member__id", - ) + users = ( + users + .distinct() + .values( + "member__avatar_url", + "member__display_name", + "member__id", ) + ) response_data["user_mention"] = list(users[:count]) @@ -330,12 +389,15 @@ class SearchEndpoint(BaseAPIView): projects = ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) | Q(network=2), + Q(project_projectmember__member=self.request.user) + | Q(network=2), workspace__slug=slug, ) .order_by("-created_at") .distinct() - .values("name", "id", "identifier", "logo_props", "workspace__slug")[:count] + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] ) response_data["project"] = list(projects) @@ -394,16 +456,20 @@ class SearchEndpoint(BaseAPIView): .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), then=Value("CURRENT"), ), When( start_date__gt=timezone.now(), then=Value("UPCOMING"), ), - When(end_date__lt=timezone.now(), then=Value("COMPLETED")), When( - Q(start_date__isnull=True) & Q(end_date__isnull=True), + end_date__lt=timezone.now(), then=Value("COMPLETED") + ), + When( + Q(start_date__isnull=True) + & Q(end_date__isnull=True), then=Value("DRAFT"), ), default=Value("DRAFT"), @@ -521,7 +587,9 @@ class SearchEndpoint(BaseAPIView): ) ) .order_by("-created_at") - .values("member__avatar_url", "member__display_name", "member__id")[:count] + .values( + "member__avatar_url", "member__display_name", "member__id" + )[:count] ) response_data["user_mention"] = list(users) @@ -535,12 +603,15 @@ class SearchEndpoint(BaseAPIView): projects = ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) | Q(network=2), + Q(project_projectmember__member=self.request.user) + | Q(network=2), workspace__slug=slug, ) .order_by("-created_at") .distinct() - .values("name", "id", "identifier", "logo_props", "workspace__slug")[:count] + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] ) response_data["project"] = list(projects) @@ -597,16 +668,20 @@ class SearchEndpoint(BaseAPIView): .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), then=Value("CURRENT"), ), When( start_date__gt=timezone.now(), then=Value("UPCOMING"), ), - When(end_date__lt=timezone.now(), then=Value("COMPLETED")), When( - Q(start_date__isnull=True) & Q(end_date__isnull=True), + end_date__lt=timezone.now(), then=Value("COMPLETED") + ), + When( + Q(start_date__isnull=True) + & Q(end_date__isnull=True), then=Value("DRAFT"), ), default=Value("DRAFT"), diff --git a/apps/api/plane/app/views/state/base.py b/apps/api/plane/app/views/state/base.py index de8d939535..bb5026b0fe 100644 --- a/apps/api/plane/app/views/state/base.py +++ b/apps/api/plane/app/views/state/base.py @@ -117,7 +117,7 @@ class StateViewSet(BaseViewSet): ) # Check for any issues in the state - issue_exist = Issue.issue_objects.filter(state=pk).exists() + issue_exist = Issue.objects.filter(state=pk).exists() if issue_exist: return Response( diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py index c8622277e2..25a7cf5671 100644 --- a/apps/api/plane/authentication/adapter/error.py +++ b/apps/api/plane/authentication/adapter/error.py @@ -38,9 +38,11 @@ AUTHENTICATION_ERROR_CODES = { "GITHUB_NOT_CONFIGURED": 5110, "GITHUB_USER_NOT_IN_ORG": 5122, "GITLAB_NOT_CONFIGURED": 5111, + "GITEA_NOT_CONFIGURED": 5112, "GOOGLE_OAUTH_PROVIDER_ERROR": 5115, "GITHUB_OAUTH_PROVIDER_ERROR": 5120, "GITLAB_OAUTH_PROVIDER_ERROR": 5121, + "GITEA_OAUTH_PROVIDER_ERROR": 5123, # Reset Password "INVALID_PASSWORD_TOKEN": 5125, "EXPIRED_PASSWORD_TOKEN": 5130, diff --git a/apps/api/plane/authentication/adapter/oauth.py b/apps/api/plane/authentication/adapter/oauth.py index ed1201097d..d8e423d0e7 100644 --- a/apps/api/plane/authentication/adapter/oauth.py +++ b/apps/api/plane/authentication/adapter/oauth.py @@ -48,6 +48,8 @@ class OauthAdapter(Adapter): return "GITHUB_OAUTH_PROVIDER_ERROR" elif self.provider == "gitlab": return "GITLAB_OAUTH_PROVIDER_ERROR" + elif self.provider == "gitea": + return "GITEA_OAUTH_PROVIDER_ERROR" else: return "OAUTH_NOT_CONFIGURED" diff --git a/apps/api/plane/authentication/provider/oauth/gitea.py b/apps/api/plane/authentication/provider/oauth/gitea.py new file mode 100644 index 0000000000..ba7d3d16ba --- /dev/null +++ b/apps/api/plane/authentication/provider/oauth/gitea.py @@ -0,0 +1,171 @@ +import os +from datetime import datetime, timedelta +from urllib.parse import urlencode, urlparse +import pytz +import requests + +# Module imports +from plane.authentication.adapter.oauth import OauthAdapter +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) + + +class GiteaOAuthProvider(OauthAdapter): + provider = "gitea" + scope = "openid email profile" + + def __init__(self, request, code=None, state=None, callback=None): + (GITEA_CLIENT_ID, GITEA_CLIENT_SECRET, GITEA_HOST) = get_configuration_value( + [ + { + "key": "GITEA_CLIENT_ID", + "default": os.environ.get("GITEA_CLIENT_ID"), + }, + { + "key": "GITEA_CLIENT_SECRET", + "default": os.environ.get("GITEA_CLIENT_SECRET"), + }, + { + "key": "GITEA_HOST", + "default": os.environ.get("GITEA_HOST"), + }, + ] + ) + + if not (GITEA_CLIENT_ID and GITEA_CLIENT_SECRET and GITEA_HOST): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_NOT_CONFIGURED"], + error_message="GITEA_NOT_CONFIGURED", + ) + + # Enforce scheme and normalize trailing slash(es) + parsed = urlparse(GITEA_HOST) + if not parsed.scheme or parsed.scheme not in ("https", "http"): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_NOT_CONFIGURED"], + error_message="GITEA_NOT_CONFIGURED", # avoid leaking details to query params + ) + GITEA_HOST = GITEA_HOST.rstrip("/") + + # Set URLs based on the host + self.token_url = f"{GITEA_HOST}/login/oauth/access_token" + self.userinfo_url = f"{GITEA_HOST}/api/v1/user" + + client_id = GITEA_CLIENT_ID + client_secret = GITEA_CLIENT_SECRET + + redirect_uri = f"{'https' if request.is_secure() else 'http'}://{request.get_host()}/auth/gitea/callback/" + url_params = { + "client_id": client_id, + "scope": self.scope, + "redirect_uri": redirect_uri, + "response_type": "code", + "state": state, + } + auth_url = f"{GITEA_HOST}/login/oauth/authorize?{urlencode(url_params)}" + + super().__init__( + request, + self.provider, + client_id, + self.scope, + redirect_uri, + auth_url, + self.token_url, + self.userinfo_url, + client_secret, + code, + callback=callback, + ) + + def set_token_data(self): + data = { + "code": self.code, + "client_id": self.client_id, + "client_secret": self.client_secret, + "redirect_uri": self.redirect_uri, + "grant_type": "authorization_code", + } + headers = {"Accept": "application/json"} + token_response = self.get_user_token(data=data, headers=headers) + super().set_token_data( + { + "access_token": token_response.get("access_token"), + "refresh_token": token_response.get("refresh_token", None), + "access_token_expired_at": ( + datetime.now(tz=pytz.utc) + timedelta(seconds=token_response.get("expires_in")) + if token_response.get("expires_in") + else None + ), + "refresh_token_expired_at": ( + datetime.fromtimestamp( + token_response.get("refresh_token_expired_at"), tz=pytz.utc + ) + if token_response.get("refresh_token_expired_at") + else None + ), + "id_token": token_response.get("id_token", ""), + } + ) + + def __get_email(self, headers): + try: + # Gitea may not provide email in user response, so fetch it separately + emails_url = f"{self.userinfo_url}/emails" + response = requests.get(emails_url, headers=headers) + if not response.ok: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR: Failed to fetch emails", + ) + emails_response = response.json() + + if not emails_response: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR: No emails found", + ) + # Prefer primary+verified, then any verified, then primary, else first + email = next((e.get("email") for e in emails_response if e.get("primary") and e.get("verified")), None) + if not email: + email = next((e.get("email") for e in emails_response if e.get("verified")), None) + if not email: + email = next((e.get("email") for e in emails_response if e.get("primary")), None) + if not email and emails_response: + # If no primary email, use the first one + email = emails_response[0].get("email") + return email + except requests.RequestException: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR: Exception occurred while fetching emails", + ) + + def set_user_data(self): + user_info_response = self.get_user_response() + headers = { + "Authorization": f"Bearer {self.token_data.get('access_token')}", + "Accept": "application/json", + } + + # Get email if not provided in user info + email = user_info_response.get("email") + if not email: + email = self.__get_email(headers=headers) + + super().set_user_data( + { + "email": email, + "user": { + "provider_id": str(user_info_response.get("id")), + "email": email, + "avatar": user_info_response.get("avatar_url"), + "first_name": user_info_response.get("full_name") or user_info_response.get("login"), + "last_name": "", # Gitea doesn't provide separate first/last name + "is_password_autoset": True, + }, + } + ) \ No newline at end of file diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py index d8b5799de1..64b8e654c9 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -36,6 +36,10 @@ from .views import ( SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint, SignOutAuthSpaceEndpoint, + GiteaCallbackEndpoint, + GiteaOauthInitiateEndpoint, + GiteaCallbackSpaceEndpoint, + GiteaOauthInitiateSpaceEndpoint, ) urlpatterns = [ @@ -129,4 +133,17 @@ urlpatterns = [ ), path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"), path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"), + ## Gitea Oauth + path("gitea/", GiteaOauthInitiateEndpoint.as_view(), name="gitea-initiate"), + path("gitea/callback/", GiteaCallbackEndpoint.as_view(), name="gitea-callback"), + path( + "spaces/gitea/", + GiteaOauthInitiateSpaceEndpoint.as_view(), + name="space-gitea-initiate", + ), + path( + "spaces/gitea/callback/", + GiteaCallbackSpaceEndpoint.as_view(), + name="space-gitea-callback", + ), ] diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py index 24ae1f673f..2595d2e756 100644 --- a/apps/api/plane/authentication/views/__init__.py +++ b/apps/api/plane/authentication/views/__init__.py @@ -5,6 +5,7 @@ from .app.check import EmailCheckEndpoint from .app.email import SignInAuthEndpoint, SignUpAuthEndpoint from .app.github import GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint +from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint @@ -17,6 +18,8 @@ from .space.github import GitHubCallbackSpaceEndpoint, GitHubOauthInitiateSpaceE from .space.gitlab import GitLabCallbackSpaceEndpoint, GitLabOauthInitiateSpaceEndpoint +from .space.gitea import GiteaCallbackSpaceEndpoint, GiteaOauthInitiateSpaceEndpoint + from .space.google import GoogleCallbackSpaceEndpoint, GoogleOauthInitiateSpaceEndpoint from .space.magic import ( diff --git a/apps/api/plane/authentication/views/app/gitea.py b/apps/api/plane/authentication/views/app/gitea.py new file mode 100644 index 0000000000..fd12f8b336 --- /dev/null +++ b/apps/api/plane/authentication/views/app/gitea.py @@ -0,0 +1,109 @@ +import uuid +from urllib.parse import urlencode, urljoin + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.oauth.gitea import GiteaOAuthProvider +from plane.authentication.utils.login import user_login +from plane.authentication.utils.redirection_path import get_redirection_path +from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.path_validator import validate_next_path + + +class GiteaOauthInitiateEndpoint(View): + def get(self, request): + # Get host and next path + request.session["host"] = base_host(request=request, is_app=True) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(validate_next_path(next_path)) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = urljoin( + base_host(request=request, is_app=True), "?" + urlencode(params) + ) + return HttpResponseRedirect(url) + try: + state = uuid.uuid4().hex + provider = GiteaOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = urljoin( + base_host(request=request, is_app=True), "?" + urlencode(params) + ) + return HttpResponseRedirect(url) + + +class GiteaCallbackEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + base_host = request.session.get("host") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin(base_host, "?" + urlencode(params)) + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = urljoin(base_host, "?" + urlencode(params)) + return HttpResponseRedirect(url) + + try: + provider = GiteaOAuthProvider( + request=request, code=code, callback=post_user_auth_workflow + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_app=True) + # Get the redirection path + if next_path: + path = str(validate_next_path(next_path)) + else: + path = get_redirection_path(user=user) + # redirect to referer path + url = urljoin(base_host, path) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = urljoin(base_host, "?" + urlencode(params)) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/gitea.py b/apps/api/plane/authentication/views/space/gitea.py new file mode 100644 index 0000000000..497a1ecc09 --- /dev/null +++ b/apps/api/plane/authentication/views/space/gitea.py @@ -0,0 +1,100 @@ +# Python imports +import uuid +from urllib.parse import urlencode + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.oauth.gitea import GiteaOAuthProvider +from plane.authentication.utils.login import user_login +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) +from plane.utils.path_validator import validate_next_path + + +class GiteaOauthInitiateSpaceEndpoint(View): + def get(self, request): + # Get host and next path + request.session["host"] = base_host(request=request, is_space=True) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(validate_next_path(next_path)) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = GiteaOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + +class GiteaCallbackSpaceEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + try: + provider = GiteaOAuthProvider(request=request, code=code) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_space=True) + # Process workspace and project invitations + # redirect to referer path + url = ( + f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}" + ) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) diff --git a/apps/api/plane/bgtasks/cleanup_task.py b/apps/api/plane/bgtasks/cleanup_task.py index 8623c9646a..6b23f2571d 100644 --- a/apps/api/plane/bgtasks/cleanup_task.py +++ b/apps/api/plane/bgtasks/cleanup_task.py @@ -410,7 +410,8 @@ def get_webhook_logs_queryset(): "response_headers", "retry_count", ) - .iterator(chunk_size=BATCH_SIZE) + .order_by("created_at") + .iterator(chunk_size=100) ) diff --git a/apps/api/plane/bgtasks/export_task.py b/apps/api/plane/bgtasks/export_task.py index fc41d7bbf9..d8aad5f69c 100644 --- a/apps/api/plane/bgtasks/export_task.py +++ b/apps/api/plane/bgtasks/export_task.py @@ -1,82 +1,24 @@ # Python imports -import csv import io -import json import zipfile from typing import List +from collections import defaultdict import boto3 from botocore.client import Config from uuid import UUID -from datetime import datetime, date # Third party imports from celery import shared_task - # Django imports from django.conf import settings from django.utils import timezone -from openpyxl import Workbook -from django.db.models import F, Prefetch - -from collections import defaultdict +from django.db.models import Prefetch # Module imports -from plane.db.models import ExporterHistory, Issue, FileAsset, Label, User, IssueComment +from plane.db.models import ExporterHistory, Issue, IssueRelation from plane.utils.exception_logger import log_exception - - -def dateTimeConverter(time: datetime) -> str | None: - """ - Convert a datetime object to a formatted string. - """ - if time: - return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z") - - -def dateConverter(time: date) -> str | None: - """ - Convert a date object to a formatted string. - """ - if time: - return time.strftime("%a, %d %b %Y") - - -def create_csv_file(data: List[List[str]]) -> str: - """ - Create a CSV file from the provided data. - """ - csv_buffer = io.StringIO() - csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL) - - for row in data: - csv_writer.writerow(row) - - csv_buffer.seek(0) - return csv_buffer.getvalue() - - -def create_json_file(data: List[dict]) -> str: - """ - Create a JSON file from the provided data. - """ - return json.dumps(data) - - -def create_xlsx_file(data: List[List[str]]) -> bytes: - """ - Create an XLSX file from the provided data. - """ - workbook = Workbook() - sheet = workbook.active - - for row in data: - sheet.append(row) - - xlsx_buffer = io.BytesIO() - workbook.save(xlsx_buffer) - xlsx_buffer.seek(0) - return xlsx_buffer.getvalue() +from plane.utils.exporters import Exporter, IssueExportSchema def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO: @@ -118,7 +60,9 @@ def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug: # Generate presigned url for the uploaded file with different base presign_s3 = boto3.client( "s3", - endpoint_url=f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/", # noqa: E501 + endpoint_url=( + f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/" + ), aws_access_key_id=settings.AWS_ACCESS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, config=Config(signature_version="s3v4"), @@ -176,187 +120,6 @@ def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug: exporter_instance.save(update_fields=["status", "url", "key"]) -def generate_table_row(issue: dict) -> List[str]: - """ - Generate a table row from an issue dictionary. - """ - return [ - f"""{issue["project_identifier"]}-{issue["sequence_id"]}""", - issue["project_name"], - issue["name"], - issue["description"], - issue["state_name"], - dateConverter(issue["start_date"]), - dateConverter(issue["target_date"]), - issue["priority"], - issue["created_by"], - ", ".join(issue["labels"]) if issue["labels"] else "", - issue["cycle_name"], - issue["cycle_start_date"], - issue["cycle_end_date"], - ", ".join(issue.get("module_name", "")) if issue.get("module_name") else "", - dateTimeConverter(issue["created_at"]), - dateTimeConverter(issue["updated_at"]), - dateTimeConverter(issue["completed_at"]), - dateTimeConverter(issue["archived_at"]), - ( - ", ".join( - [ - f"{comment['comment']} ({comment['created_at']} by {comment['created_by']})" - for comment in issue["comments"] - ] - ) - if issue["comments"] - else "" - ), - issue["estimate"] if issue["estimate"] else "", - ", ".join(issue["link"]) if issue["link"] else "", - ", ".join(issue["assignees"]) if issue["assignees"] else "", - issue["subscribers_count"] if issue["subscribers_count"] else "", - issue["attachment_count"] if issue["attachment_count"] else "", - ", ".join(issue["attachment_links"]) if issue["attachment_links"] else "", - ] - - -def generate_json_row(issue: dict) -> dict: - """ - Generate a JSON row from an issue dictionary. - """ - return { - "ID": f"""{issue["project_identifier"]}-{issue["sequence_id"]}""", - "Project": issue["project_name"], - "Name": issue["name"], - "Description": issue["description"], - "State": issue["state_name"], - "Start Date": dateConverter(issue["start_date"]), - "Target Date": dateConverter(issue["target_date"]), - "Priority": issue["priority"], - "Created By": (f"{issue['created_by']}" if issue["created_by"] else ""), - "Assignee": issue["assignees"], - "Labels": issue["labels"], - "Cycle Name": issue["cycle_name"], - "Cycle Start Date": issue["cycle_start_date"], - "Cycle End Date": issue["cycle_end_date"], - "Module Name": issue["module_name"], - "Created At": dateTimeConverter(issue["created_at"]), - "Updated At": dateTimeConverter(issue["updated_at"]), - "Completed At": dateTimeConverter(issue["completed_at"]), - "Archived At": dateTimeConverter(issue["archived_at"]), - "Comments": issue["comments"], - "Estimate": issue["estimate"], - "Link": issue["link"], - "Subscribers Count": issue["subscribers_count"], - "Attachment Count": issue["attachment_count"], - "Attachment Links": issue["attachment_links"], - } - - -def update_json_row(rows: List[dict], row: dict) -> None: - """ - Update the json row with the new assignee and label. - """ - matched_index = next( - (index for index, existing_row in enumerate(rows) if existing_row["ID"] == row["ID"]), - None, - ) - - if matched_index is not None: - existing_assignees, existing_labels = ( - rows[matched_index]["Assignee"], - rows[matched_index]["Labels"], - ) - assignee, label = row["Assignee"], row["Labels"] - - if assignee is not None and (existing_assignees is None or label not in existing_assignees): - rows[matched_index]["Assignee"] += f", {assignee}" - if label is not None and (existing_labels is None or label not in existing_labels): - rows[matched_index]["Labels"] += f", {label}" - else: - rows.append(row) - - -def update_table_row(rows: List[List[str]], row: List[str]) -> None: - """ - Update the table row with the new assignee and label. - """ - matched_index = next( - (index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]), - None, - ) - - if matched_index is not None: - existing_assignees, existing_labels = rows[matched_index][7:9] - assignee, label = row[7:9] - - if assignee is not None and (existing_assignees is None or label not in existing_assignees): - rows[matched_index][8] += f", {assignee}" - if label is not None and (existing_labels is None or label not in existing_labels): - rows[matched_index][8] += f", {label}" - else: - rows.append(row) - - -def generate_csv( - header: List[str], - project_id: str, - issues: List[dict], - files: List[tuple[str, str | bytes]], -) -> None: - """ - Generate CSV export for all the passed issues. - """ - rows = [header] - for issue in issues: - row = generate_table_row(issue) - update_table_row(rows, row) - csv_file = create_csv_file(rows) - files.append((f"{project_id}.csv", csv_file)) - - -def generate_json( - header: List[str], - project_id: str, - issues: List[dict], - files: List[tuple[str, str | bytes]], -) -> None: - """ - Generate JSON export for all the passed issues. - """ - rows = [] - for issue in issues: - row = generate_json_row(issue) - update_json_row(rows, row) - json_file = create_json_file(rows) - files.append((f"{project_id}.json", json_file)) - - -def generate_xlsx( - header: List[str], - project_id: str, - issues: List[dict], - files: List[tuple[str, str | bytes]], -) -> None: - """ - Generate XLSX export for all the passed issues. - """ - rows = [header] - for issue in issues: - row = generate_table_row(issue) - - update_table_row(rows, row) - xlsx_file = create_xlsx_file(rows) - files.append((f"{project_id}.xlsx", xlsx_file)) - - -def get_created_by(obj: Issue | IssueComment) -> str: - """ - Get the created by user for the given object. - """ - if obj.created_by: - return f"{obj.created_by.first_name} {obj.created_by.last_name}" - return "" - - @shared_task def issue_export_task( provider: str, @@ -377,7 +140,7 @@ def issue_export_task( exporter_instance.status = "processing" exporter_instance.save(update_fields=["status"]) - # Base query to get the issues + # Build base queryset for issues workspace_issues = ( Issue.objects.filter( workspace__id=workspace_id, @@ -390,7 +153,6 @@ def issue_export_task( "project", "workspace", "state", - "parent", "created_by", "estimate_point", ) @@ -400,144 +162,51 @@ def issue_export_task( "issue_module__module", "issue_comments", "assignees", - Prefetch( - "assignees", - queryset=User.objects.only("first_name", "last_name").distinct(), - to_attr="assignee_details", - ), - Prefetch( - "labels", - queryset=Label.objects.only("name").distinct(), - to_attr="label_details", - ), "issue_subscribers", "issue_link", + Prefetch( + "issue_relation", + queryset=IssueRelation.objects.select_related("related_issue", "related_issue__project"), + ), + Prefetch( + "issue_related", + queryset=IssueRelation.objects.select_related("issue", "issue__project"), + ), + Prefetch( + "parent", + queryset=Issue.objects.select_related("type", "project"), + ), ) ) - # Get the attachments for the issues - file_assets = FileAsset.objects.filter( - issue_id__in=workspace_issues.values_list("id", flat=True), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ).annotate(work_item_id=F("issue_id"), asset_id=F("id")) - - # Create a dictionary to store the attachments for the issues - attachment_dict = defaultdict(list) - for asset in file_assets: - attachment_dict[asset.work_item_id].append(asset.asset_id) - - # Create a list to store the issues data - issues_data = [] - - # Iterate over the issues - for issue in workspace_issues: - attachments = attachment_dict.get(issue.id, []) - - issue_data = { - "id": issue.id, - "project_identifier": issue.project.identifier, - "project_name": issue.project.name, - "project_id": issue.project.id, - "sequence_id": issue.sequence_id, - "name": issue.name, - "description": issue.description_stripped, - "priority": issue.priority, - "start_date": issue.start_date, - "target_date": issue.target_date, - "state_name": issue.state.name if issue.state else None, - "created_at": issue.created_at, - "updated_at": issue.updated_at, - "completed_at": issue.completed_at, - "archived_at": issue.archived_at, - "module_name": [module.module.name for module in issue.issue_module.all()], - "created_by": get_created_by(issue), - "labels": [label.name for label in issue.label_details], - "comments": [ - { - "comment": comment.comment_stripped, - "created_at": dateConverter(comment.created_at), - "created_by": get_created_by(comment), - } - for comment in issue.issue_comments.all() - ], - "estimate": issue.estimate_point.value if issue.estimate_point and issue.estimate_point.value else "", - "link": [link.url for link in issue.issue_link.all()], - "assignees": [f"{assignee.first_name} {assignee.last_name}" for assignee in issue.assignee_details], - "subscribers_count": issue.issue_subscribers.count(), - "attachment_count": len(attachments), - "attachment_links": [ - f"/api/assets/v2/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{asset}/" - for asset in attachments - ], - } - - # Get Cycles data for the issue - cycle = issue.issue_cycle.last() - if cycle: - # Update cycle data - issue_data["cycle_name"] = cycle.cycle.name - issue_data["cycle_start_date"] = dateConverter(cycle.cycle.start_date) - issue_data["cycle_end_date"] = dateConverter(cycle.cycle.end_date) - else: - issue_data["cycle_name"] = "" - issue_data["cycle_start_date"] = "" - issue_data["cycle_end_date"] = "" - - issues_data.append(issue_data) - - # CSV header - header = [ - "ID", - "Project", - "Name", - "Description", - "State", - "Start Date", - "Target Date", - "Priority", - "Created By", - "Labels", - "Cycle Name", - "Cycle Start Date", - "Cycle End Date", - "Module Name", - "Created At", - "Updated At", - "Completed At", - "Archived At", - "Comments", - "Estimate", - "Link", - "Assignees", - "Subscribers Count", - "Attachment Count", - "Attachment Links", - ] - - # Map the provider to the function - EXPORTER_MAPPER = { - "csv": generate_csv, - "json": generate_json, - "xlsx": generate_xlsx, - } + # Create exporter for the specified format + try: + exporter = Exporter( + format_type=provider, + schema_class=IssueExportSchema, + options={"list_joiner": ", "}, + ) + except ValueError as e: + # Invalid format type + exporter_instance = ExporterHistory.objects.get(token=token_id) + exporter_instance.status = "failed" + exporter_instance.reason = str(e) + exporter_instance.save(update_fields=["status", "reason"]) + return files = [] if multiple: - project_dict = defaultdict(list) - for issue in issues_data: - project_dict[str(issue["project_id"])].append(issue) - + # Export each project separately with its own queryset for project_id in project_ids: - issues = project_dict.get(str(project_id), []) - - exporter = EXPORTER_MAPPER.get(provider) - if exporter is not None: - exporter(header, project_id, issues, files) - + project_issues = workspace_issues.filter(project_id=project_id) + export_filename = f"{slug}-{project_id}" + filename, content = exporter.export(export_filename, project_issues) + files.append((filename, content)) else: - exporter = EXPORTER_MAPPER.get(provider) - if exporter is not None: - exporter(header, workspace_id, issues_data, files) + # Export all issues in a single file + export_filename = f"{slug}-{workspace_id}" + filename, content = exporter.export(export_filename, workspace_issues) + files.append((filename, content)) zip_buffer = create_zip_file(files) upload_to_s3(zip_buffer, workspace_id, token_id, slug) diff --git a/apps/api/plane/bgtasks/page_transaction_task.py b/apps/api/plane/bgtasks/page_transaction_task.py index 09e2cb2add..402d0a3ee0 100644 --- a/apps/api/plane/bgtasks/page_transaction_task.py +++ b/apps/api/plane/bgtasks/page_transaction_task.py @@ -1,5 +1,5 @@ # Python imports -import json +import logging # Django imports from django.utils import timezone @@ -7,72 +7,134 @@ from django.utils import timezone # Third-party imports from bs4 import BeautifulSoup -# Module imports -from plane.db.models import Page, PageLog +# App imports from celery import shared_task +from plane.db.models import Page, PageLog from plane.utils.exception_logger import log_exception +logger = logging.getLogger("plane.worker") -def extract_components(value, tag): +COMPONENT_MAP = { + "mention-component": { + "attributes": ["id", "entity_identifier", "entity_name", "entity_type"], + "extract": lambda m: { + "entity_name": m.get("entity_name"), + "entity_type": None, + "entity_identifier": m.get("entity_identifier"), + }, + }, + "image-component": { + "attributes": ["id", "src"], + "extract": lambda m: { + "entity_name": "image", + "entity_type": None, + "entity_identifier": m.get("src"), + }, + }, +} + +component_map = { + **COMPONENT_MAP, +} + + +def extract_all_components(description_html): + """ + Extracts all component types from the HTML value in a single pass. + Returns a dict mapping component_type -> list of extracted entities. + """ try: - mentions = [] - html = value.get("description_html") - soup = BeautifulSoup(html, "html.parser") - mention_tags = soup.find_all(tag) + if not description_html: + return {component: [] for component in component_map.keys()} - for mention_tag in mention_tags: - mention = { - "id": mention_tag.get("id"), - "entity_identifier": mention_tag.get("entity_identifier"), - "entity_name": mention_tag.get("entity_name"), - } - mentions.append(mention) + soup = BeautifulSoup(description_html, "html.parser") + results = {} + + for component, config in component_map.items(): + attributes = config.get("attributes", ["id"]) + component_tags = soup.find_all(component) + + entities = [] + for tag in component_tags: + entity = {attr: tag.get(attr) for attr in attributes} + entities.append(entity) + + results[component] = entities + + return results - return mentions except Exception: - return [] + return {component: [] for component in component_map.keys()} + + +def get_entity_details(component: str, mention: dict): + """ + Normalizes mention attributes into entity_name, entity_type, entity_identifier. + """ + config = component_map.get(component) + if not config: + return {"entity_name": None, "entity_type": None, "entity_identifier": None} + return config["extract"](mention) @shared_task -def page_transaction(new_value, old_value, page_id): +def page_transaction(new_description_html, old_description_html, page_id): + """ + Tracks changes in page content (mentions, embeds, etc.) + and logs them in PageLog for audit and reference. + """ try: page = Page.objects.get(pk=page_id) - new_page_mention = PageLog.objects.filter(page_id=page_id).exists() - old_value = json.loads(old_value) if old_value else {} + has_existing_logs = PageLog.objects.filter(page_id=page_id).exists() + + + # Extract all components in a single pass (optimized) + old_components = extract_all_components(old_description_html) + new_components = extract_all_components(new_description_html) new_transactions = [] deleted_transaction_ids = set() - # TODO - Add "issue-embed-component", "img", "todo" components - components = ["mention-component"] - for component in components: - old_mentions = extract_components(old_value, component) - new_mentions = extract_components(new_value, component) + for component in component_map.keys(): + old_entities = old_components[component] + new_entities = new_components[component] - new_mentions_ids = {mention["id"] for mention in new_mentions} - old_mention_ids = {mention["id"] for mention in old_mentions} - deleted_transaction_ids.update(old_mention_ids - new_mentions_ids) + old_ids = {m.get("id") for m in old_entities if m.get("id")} + new_ids = {m.get("id") for m in new_entities if m.get("id")} + deleted_transaction_ids.update(old_ids - new_ids) - new_transactions.extend( - PageLog( - transaction=mention["id"], - page_id=page_id, - entity_identifier=mention["entity_identifier"], - entity_name=mention["entity_name"], - workspace_id=page.workspace_id, - created_at=timezone.now(), - updated_at=timezone.now(), + for mention in new_entities: + mention_id = mention.get("id") + if not mention_id or (mention_id in old_ids and has_existing_logs): + continue + + details = get_entity_details(component, mention) + current_time = timezone.now() + + new_transactions.append( + PageLog( + transaction=mention_id, + page_id=page_id, + entity_identifier=details["entity_identifier"], + entity_name=details["entity_name"], + entity_type=details["entity_type"], + workspace_id=page.workspace_id, + created_at=current_time, + updated_at=current_time, + ) ) - for mention in new_mentions - if mention["id"] not in old_mention_ids or not new_page_mention + + + # Bulk insert and cleanup + if new_transactions: + PageLog.objects.bulk_create( + new_transactions, batch_size=50, ignore_conflicts=True ) - # Create new PageLog objects for new transactions - PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True) + if deleted_transaction_ids: + PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete() - # Delete the removed transactions - PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete() except Page.DoesNotExist: return except Exception as e: diff --git a/apps/api/plane/bgtasks/webhook_task.py b/apps/api/plane/bgtasks/webhook_task.py index df36ce815c..3d04a65b71 100644 --- a/apps/api/plane/bgtasks/webhook_task.py +++ b/apps/api/plane/bgtasks/webhook_task.py @@ -48,6 +48,8 @@ from plane.db.models import ( ) from plane.license.utils.instance_value import get_email_configuration from plane.utils.exception_logger import log_exception +from plane.settings.mongo import MongoConnection + SERIALIZER_MAPPER = { "project": ProjectSerializer, @@ -84,6 +86,56 @@ def get_issue_prefetches(): ] +def save_webhook_log( + webhook: Webhook, + request_method: str, + request_headers: str, + request_body: str, + response_status: str, + response_headers: str, + response_body: str, + retry_count: int, + event_type: str, +) -> None: + # webhook_logs + mongo_collection = MongoConnection.get_collection("webhook_logs") + + log_data = { + "workspace_id": str(webhook.workspace_id), + "webhook": str(webhook.id), + "event_type": str(event_type), + "request_method": str(request_method), + "request_headers": str(request_headers), + "request_body": str(request_body), + "response_status": str(response_status), + "response_headers": str(response_headers), + "response_body": str(response_body), + "retry_count": retry_count, + } + + mongo_save_success = False + if mongo_collection is not None: + try: + # insert the log data into the mongo collection + mongo_collection.insert_one(log_data) + logger.info("Webhook log saved successfully to mongo") + mongo_save_success = True + except Exception as e: + log_exception(e, warning=True) + logger.error(f"Failed to save webhook log: {e}") + mongo_save_success = False + + # if the mongo save is not successful, save the log data into the database + if not mongo_save_success: + try: + # insert the log data into the database + WebhookLog.objects.create(**log_data) + logger.info("Webhook log saved successfully to database") + except Exception as e: + log_exception(e, warning=True) + logger.error(f"Failed to save webhook log: {e}") + + def get_model_data(event: str, event_id: Union[str, List[str]], many: bool = False) -> Dict[str, Any]: """ Retrieve and serialize model data based on the event type. @@ -190,7 +242,7 @@ def send_webhook_deactivation_email(webhook_id: str, receiver_id: str, current_s msg.send() logger.info("Email sent successfully.") except Exception as e: - log_exception(e) + log_exception(e, warning=True) logger.error(f"Failed to send email: {e}") @@ -273,32 +325,30 @@ def webhook_send_task( response = requests.post(webhook.url, headers=headers, json=payload, timeout=30) # Log the webhook request - WebhookLog.objects.create( - workspace_id=str(webhook.workspace_id), - webhook=str(webhook.id), - event_type=str(event), - request_method=str(action), - request_headers=str(headers), - request_body=str(payload), - response_status=str(response.status_code), - response_headers=str(response.headers), - response_body=str(response.text), - retry_count=str(self.request.retries), + save_webhook_log( + webhook=webhook, + request_method=action, + request_headers=headers, + request_body=payload, + response_status=response.status_code, + response_headers=response.headers, + response_body=response.text, + retry_count=self.request.retries, + event_type=event, ) logger.info(f"Webhook {webhook.id} sent successfully") except requests.RequestException as e: # Log the failed webhook request - WebhookLog.objects.create( - workspace_id=str(webhook.workspace_id), - webhook=str(webhook.id), - event_type=str(event), - request_method=str(action), - request_headers=str(headers), - request_body=str(payload), + save_webhook_log( + webhook=webhook, + request_method=action, + request_headers=headers, + request_body=payload, response_status=500, response_headers="", response_body=str(e), - retry_count=str(self.request.retries), + retry_count=self.request.retries, + event_type=event, ) logger.error(f"Webhook {webhook.id} failed with error: {e}") # Retry logic diff --git a/apps/api/plane/bgtasks/work_item_link_task.py b/apps/api/plane/bgtasks/work_item_link_task.py index 721231be1e..7ceaacaf5a 100644 --- a/apps/api/plane/bgtasks/work_item_link_task.py +++ b/apps/api/plane/bgtasks/work_item_link_task.py @@ -171,8 +171,12 @@ def fetch_and_encode_favicon( @shared_task def crawl_work_item_link_title(id: str, url: str) -> None: meta_data = crawl_work_item_link_title_and_favicon(url) - issue_link = IssueLink.objects.get(id=id) + + try: + issue_link = IssueLink.objects.get(id=id) + except IssueLink.DoesNotExist: + logger.warning(f"IssueLink not found for the id {id} and the url {url}") + return issue_link.metadata = meta_data - issue_link.save() diff --git a/apps/api/plane/db/migrations/0108_alter_issueactivity_issue_comment.py b/apps/api/plane/db/migrations/0108_alter_issueactivity_issue_comment.py new file mode 100644 index 0000000000..9bc55076a7 --- /dev/null +++ b/apps/api/plane/db/migrations/0108_alter_issueactivity_issue_comment.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.25 on 2025-11-05 07:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0107_migrate_filters_to_rich_filters'), + ] + + operations = [ + migrations.AlterField( + model_name='issueactivity', + name='issue_comment', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='issue_comment', to='db.issuecomment'), + ), + migrations.AlterField( + model_name='issueactivity', + name='issue', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='issue_activity', to='db.issue'), + ), + ] diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index 9412a57c62..8f9de34093 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -273,6 +273,21 @@ class IssueRelationChoices(models.TextChoices): IMPLEMENTED_BY = "implemented_by", "Implemented By" +# Bidirectional relation pairs: (forward, reverse) +# Defined after class to avoid enum metaclass conflicts +IssueRelationChoices._RELATION_PAIRS = ( + ("blocked_by", "blocking"), + ("relates_to", "relates_to"), # symmetric + ("duplicate", "duplicate"), # symmetric + ("start_before", "start_after"), + ("finish_before", "finish_after"), + ("implemented_by", "implements"), +) + +# Generate reverse mapping from pairs +IssueRelationChoices._REVERSE_MAPPING = {forward: reverse for forward, reverse in IssueRelationChoices._RELATION_PAIRS} + + class IssueRelation(ProjectBaseModel): issue = models.ForeignKey(Issue, related_name="issue_relation", on_delete=models.CASCADE) related_issue = models.ForeignKey(Issue, related_name="issue_related", on_delete=models.CASCADE) @@ -392,7 +407,7 @@ class IssueAttachment(ProjectBaseModel): class IssueActivity(ProjectBaseModel): - issue = models.ForeignKey(Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity") + issue = models.ForeignKey(Issue, on_delete=models.DO_NOTHING, null=True, related_name="issue_activity") verb = models.CharField(max_length=255, verbose_name="Action", default="created") field = models.CharField(max_length=255, verbose_name="Field Name", blank=True, null=True) old_value = models.TextField(verbose_name="Old Value", blank=True, null=True) @@ -402,7 +417,7 @@ class IssueActivity(ProjectBaseModel): attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) issue_comment = models.ForeignKey( "db.IssueComment", - on_delete=models.SET_NULL, + on_delete=models.DO_NOTHING, related_name="issue_comment", null=True, ) diff --git a/apps/api/plane/license/api/views/admin.py b/apps/api/plane/license/api/views/admin.py index 72c9761163..5b70beab9d 100644 --- a/apps/api/plane/license/api/views/admin.py +++ b/apps/api/plane/license/api/views/admin.py @@ -134,7 +134,8 @@ class InstanceAdminSignUpEndpoint(View): }, ) url = urljoin( - base_host(request=request, is_admin=True), + base_host(request=request, is_admin=True, ), + "?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -228,7 +229,7 @@ class InstanceAdminSignUpEndpoint(View): # get tokens for user user_login(request=request, user=user, is_admin=True) - url = urljoin(base_host(request=request, is_admin=True), "general") + url = urljoin(base_host(request=request, is_admin=True), "general/") return HttpResponseRedirect(url) @@ -347,7 +348,7 @@ class InstanceAdminSignInEndpoint(View): # get tokens for user user_login(request=request, user=user, is_admin=True) - url = urljoin(base_host(request=request, is_admin=True), "general") + url = urljoin(base_host(request=request, is_admin=True), "general/") return HttpResponseRedirect(url) diff --git a/apps/api/plane/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py index c598acfef9..23eeebec1c 100644 --- a/apps/api/plane/license/api/views/instance.py +++ b/apps/api/plane/license/api/views/instance.py @@ -50,6 +50,7 @@ class InstanceEndpoint(BaseAPIView): IS_GITHUB_ENABLED, GITHUB_APP_NAME, IS_GITLAB_ENABLED, + IS_GITEA_ENABLED, EMAIL_HOST, ENABLE_MAGIC_LINK_LOGIN, ENABLE_EMAIL_PASSWORD, @@ -86,6 +87,10 @@ class InstanceEndpoint(BaseAPIView): "key": "IS_GITLAB_ENABLED", "default": os.environ.get("IS_GITLAB_ENABLED", "0"), }, + { + "key": "IS_GITEA_ENABLED", + "default": os.environ.get("IS_GITEA_ENABLED", "0"), + }, {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")}, { "key": "ENABLE_MAGIC_LINK_LOGIN", @@ -134,6 +139,7 @@ class InstanceEndpoint(BaseAPIView): data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1" data["is_github_enabled"] = IS_GITHUB_ENABLED == "1" data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1" + data["is_gitea_enabled"] = IS_GITEA_ENABLED == "1" data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1" data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1" diff --git a/apps/api/plane/license/management/commands/configure_instance.py b/apps/api/plane/license/management/commands/configure_instance.py index 5611eec522..b3e84dd82d 100644 --- a/apps/api/plane/license/management/commands/configure_instance.py +++ b/apps/api/plane/license/management/commands/configure_instance.py @@ -6,6 +6,7 @@ from django.core.management.base import BaseCommand, CommandError # Module imports from plane.license.models import InstanceConfiguration +from plane.utils.instance_config_variables import instance_config_variables class Command(BaseCommand): @@ -21,175 +22,7 @@ class Command(BaseCommand): if not os.environ.get(item): raise CommandError(f"{item} env variable is required.") - config_keys = [ - # Authentication Settings - { - "key": "ENABLE_SIGNUP", - "value": os.environ.get("ENABLE_SIGNUP", "1"), - "category": "AUTHENTICATION", - "is_encrypted": False, - }, - { - "key": "DISABLE_WORKSPACE_CREATION", - "value": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), - "category": "WORKSPACE_MANAGEMENT", - "is_encrypted": False, - }, - { - "key": "ENABLE_EMAIL_PASSWORD", - "value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), - "category": "AUTHENTICATION", - "is_encrypted": False, - }, - { - "key": "ENABLE_MAGIC_LINK_LOGIN", - "value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"), - "category": "AUTHENTICATION", - "is_encrypted": False, - }, - { - "key": "GOOGLE_CLIENT_ID", - "value": os.environ.get("GOOGLE_CLIENT_ID"), - "category": "GOOGLE", - "is_encrypted": False, - }, - { - "key": "GOOGLE_CLIENT_SECRET", - "value": os.environ.get("GOOGLE_CLIENT_SECRET"), - "category": "GOOGLE", - "is_encrypted": True, - }, - { - "key": "GITHUB_CLIENT_ID", - "value": os.environ.get("GITHUB_CLIENT_ID"), - "category": "GITHUB", - "is_encrypted": False, - }, - { - "key": "GITHUB_CLIENT_SECRET", - "value": os.environ.get("GITHUB_CLIENT_SECRET"), - "category": "GITHUB", - "is_encrypted": True, - }, - { - "key": "GITHUB_ORGANIZATION_ID", - "value": os.environ.get("GITHUB_ORGANIZATION_ID"), - "category": "GITHUB", - "is_encrypted": False, - }, - { - "key": "GITLAB_HOST", - "value": os.environ.get("GITLAB_HOST"), - "category": "GITLAB", - "is_encrypted": False, - }, - { - "key": "GITLAB_CLIENT_ID", - "value": os.environ.get("GITLAB_CLIENT_ID"), - "category": "GITLAB", - "is_encrypted": False, - }, - { - "key": "ENABLE_SMTP", - "value": os.environ.get("ENABLE_SMTP", "0"), - "category": "SMTP", - "is_encrypted": False, - }, - { - "key": "GITLAB_CLIENT_SECRET", - "value": os.environ.get("GITLAB_CLIENT_SECRET"), - "category": "GITLAB", - "is_encrypted": True, - }, - { - "key": "EMAIL_HOST", - "value": os.environ.get("EMAIL_HOST", ""), - "category": "SMTP", - "is_encrypted": False, - }, - { - "key": "EMAIL_HOST_USER", - "value": os.environ.get("EMAIL_HOST_USER", ""), - "category": "SMTP", - "is_encrypted": False, - }, - { - "key": "EMAIL_HOST_PASSWORD", - "value": os.environ.get("EMAIL_HOST_PASSWORD", ""), - "category": "SMTP", - "is_encrypted": True, - }, - { - "key": "EMAIL_PORT", - "value": os.environ.get("EMAIL_PORT", "587"), - "category": "SMTP", - "is_encrypted": False, - }, - { - "key": "EMAIL_FROM", - "value": os.environ.get("EMAIL_FROM", ""), - "category": "SMTP", - "is_encrypted": False, - }, - { - "key": "EMAIL_USE_TLS", - "value": os.environ.get("EMAIL_USE_TLS", "1"), - "category": "SMTP", - "is_encrypted": False, - }, - { - "key": "EMAIL_USE_SSL", - "value": os.environ.get("EMAIL_USE_SSL", "0"), - "category": "SMTP", - "is_encrypted": False, - }, - { - "key": "LLM_API_KEY", - "value": os.environ.get("LLM_API_KEY"), - "category": "AI", - "is_encrypted": True, - }, - { - "key": "LLM_PROVIDER", - "value": os.environ.get("LLM_PROVIDER", "openai"), - "category": "AI", - "is_encrypted": False, - }, - { - "key": "LLM_MODEL", - "value": os.environ.get("LLM_MODEL", "gpt-4o-mini"), - "category": "AI", - "is_encrypted": False, - }, - # Deprecated, use LLM_MODEL - { - "key": "GPT_ENGINE", - "value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), - "category": "SMTP", - "is_encrypted": False, - }, - { - "key": "UNSPLASH_ACCESS_KEY", - "value": os.environ.get("UNSPLASH_ACCESS_KEY", ""), - "category": "UNSPLASH", - "is_encrypted": True, - }, - # intercom settings - { - "key": "IS_INTERCOM_ENABLED", - "value": os.environ.get("IS_INTERCOM_ENABLED", "1"), - "category": "INTERCOM", - "is_encrypted": False, - }, - { - "key": "INTERCOM_APP_ID", - "value": os.environ.get("INTERCOM_APP_ID", ""), - "category": "INTERCOM", - "is_encrypted": False, - }, - ] - - for item in config_keys: + for item in instance_config_variables: obj, created = InstanceConfiguration.objects.get_or_create(key=item.get("key")) if created: obj.category = item.get("category") @@ -203,7 +36,7 @@ class Command(BaseCommand): else: self.stdout.write(self.style.WARNING(f"{obj.key} configuration already exists")) - keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED"] + keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED", "IS_GITEA_ENABLED"] if not InstanceConfiguration.objects.filter(key__in=keys).exists(): for key in keys: if key == "IS_GOOGLE_ENABLED": @@ -282,6 +115,34 @@ class Command(BaseCommand): is_encrypted=False, ) self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) + if key == "IS_GITEA_ENABLED": + GITEA_HOST, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET = get_configuration_value( + [ + { + "key": "GITEA_HOST", + "default": os.environ.get("GITEA_HOST", ""), + }, + { + "key": "GITEA_CLIENT_ID", + "default": os.environ.get("GITEA_CLIENT_ID", ""), + }, + { + "key": "GITEA_CLIENT_SECRET", + "default": os.environ.get("GITEA_CLIENT_SECRET", ""), + }, + ] + ) + if bool(GITEA_HOST) and bool(GITEA_CLIENT_ID) and bool(GITEA_CLIENT_SECRET): + value = "1" + else: + value = "0" + InstanceConfiguration.objects.create( + key="IS_GITEA_ENABLED", + value=value, + category="AUTHENTICATION", + is_encrypted=False, + ) + self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) else: for key in keys: self.stdout.write(self.style.WARNING(f"{key} configuration already exists")) diff --git a/apps/api/plane/middleware/logger.py b/apps/api/plane/middleware/logger.py index 62f8684765..d513ee3e36 100644 --- a/apps/api/plane/middleware/logger.py +++ b/apps/api/plane/middleware/logger.py @@ -12,7 +12,6 @@ from rest_framework.request import Request from plane.utils.ip_address import get_client_ip from plane.db.models import APIActivityLog - api_logger = logging.getLogger("plane.api.request") diff --git a/apps/api/plane/middleware/request_body_size.py b/apps/api/plane/middleware/request_body_size.py new file mode 100644 index 0000000000..9807c57156 --- /dev/null +++ b/apps/api/plane/middleware/request_body_size.py @@ -0,0 +1,27 @@ +from django.core.exceptions import RequestDataTooBig +from django.http import JsonResponse + + +class RequestBodySizeLimitMiddleware: + """ + Middleware to catch RequestDataTooBig exceptions and return + 413 Request Entity Too Large instead of 400 Bad Request. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + try: + _ = request.body + except RequestDataTooBig: + return JsonResponse( + { + "error": "REQUEST_BODY_TOO_LARGE", + "detail": "The size of the request body exceeds the maximum allowed size.", + }, + status=413, + ) + + # If body size is OK, continue with the request + return self.get_response(request) diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 44a4d0d39d..d47bf6293f 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -62,6 +62,7 @@ MIDDLEWARE = [ "django.middleware.clickjacking.XFrameOptionsMiddleware", "crum.CurrentRequestUserMiddleware", "django.middleware.gzip.GZipMiddleware", + "plane.middleware.request_body_size.RequestBodySizeLimitMiddleware", "plane.middleware.logger.APITokenLogMiddleware", "plane.middleware.logger.RequestLoggerMiddleware", ] @@ -299,14 +300,14 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) SESSION_COOKIE_SECURE = secure_origins SESSION_COOKIE_HTTPONLY = True SESSION_ENGINE = "plane.db.models.session" -SESSION_COOKIE_AGE = os.environ.get("SESSION_COOKIE_AGE", 604800) +SESSION_COOKIE_AGE = int(os.environ.get("SESSION_COOKIE_AGE", 604800)) SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id") SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None) SESSION_SAVE_EVERY_REQUEST = os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1" # Admin Cookie ADMIN_SESSION_COOKIE_NAME = "admin-session-id" -ADMIN_SESSION_COOKIE_AGE = os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600) +ADMIN_SESSION_COOKIE_AGE = int(os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)) # CSRF cookies CSRF_COOKIE_SECURE = secure_origins diff --git a/apps/api/plane/utils/content_validator.py b/apps/api/plane/utils/content_validator.py index 5163fad7dd..caf740e544 100644 --- a/apps/api/plane/utils/content_validator.py +++ b/apps/api/plane/utils/content_validator.py @@ -4,7 +4,9 @@ import nh3 from plane.utils.exception_logger import log_exception from bs4 import BeautifulSoup from collections import defaultdict +import logging +logger = logging.getLogger("plane.api") # Maximum allowed size for binary data (10MB) MAX_SIZE = 10 * 1024 * 1024 @@ -54,7 +56,9 @@ def validate_binary_data(data): # Check for suspicious text patterns (HTML/JS) try: decoded_text = binary_data.decode("utf-8", errors="ignore")[:200] - if any(pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS): + if any( + pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS + ): return False, "Binary data contains suspicious content patterns" except Exception: pass # Binary data might not be decodable as text, which is fine @@ -232,10 +236,7 @@ def validate_html_content(html_content: str): summary = json.dumps(diff) except Exception: summary = str(diff) - log_exception( - f"HTML sanitization removals: {summary}", - warning=True, - ) + logger.warning(f"HTML sanitization removals: {summary}") return True, None, clean_html except Exception as e: log_exception(e) diff --git a/apps/api/plane/utils/cycle_transfer_issues.py b/apps/api/plane/utils/cycle_transfer_issues.py new file mode 100644 index 0000000000..ec934e8892 --- /dev/null +++ b/apps/api/plane/utils/cycle_transfer_issues.py @@ -0,0 +1,486 @@ +# Python imports +import json + +# Django imports +from django.db.models import ( + Case, + Count, + F, + Q, + Sum, + FloatField, + Value, + When, +) +from django.db import models +from django.db.models.functions import Cast, Concat +from django.utils import timezone + +# Module imports +from plane.db.models import ( + Cycle, + CycleIssue, + Issue, + Project, +) +from plane.utils.analytics_plot import burndown_plot +from plane.bgtasks.issue_activities_task import issue_activity +from plane.utils.host import base_host + + +def transfer_cycle_issues( + slug, + project_id, + cycle_id, + new_cycle_id, + request, + user_id, +): + """ + Transfer incomplete issues from one cycle to another and create progress snapshot. + + Args: + slug: Workspace slug + project_id: Project ID + cycle_id: Source cycle ID + new_cycle_id: Destination cycle ID + request: HTTP request object + user_id: User ID performing the transfer + + Returns: + dict: Response data with success or error message + """ + # Get the new cycle + new_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=new_cycle_id + ).first() + + # Check if new cycle is already completed + if new_cycle.end_date is not None and new_cycle.end_date < timezone.now(): + return { + "success": False, + "error": "The cycle where the issues are transferred is already completed", + } + + # Get the old cycle with issue counts + old_cycle = ( + Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + ) + old_cycle = old_cycle.first() + + if old_cycle is None: + return { + "success": False, + "error": "Source cycle not found", + } + + # Check if project uses estimates + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + # Initialize estimate distribution variables + assignee_estimate_distribution = [] + label_estimate_distribution = [] + estimate_completion_chart = {} + + if estimate_type: + assignee_estimate_data = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + # Assignee estimate distribution serialization + assignee_estimate_distribution = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) if item["assignee_id"] else None + ), + "avatar_url": item.get("avatar_url"), + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in assignee_estimate_data + ] + + label_distribution_data = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + estimate_completion_chart = burndown_plot( + queryset=old_cycle, + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=cycle_id, + ) + # Label estimate distribution serialization + label_estimate_distribution = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": (str(item["label_id"]) if item["label_id"] else None), + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in label_distribution_data + ] + + # Get the assignee distribution + assignee_distribution = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When(assignees__avatar_asset__isnull=True, then="assignees__avatar"), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") + .annotate( + total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + # Assignee distribution serialized + assignee_distribution_data = [ + { + "display_name": item["display_name"], + "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None), + "avatar_url": item.get("avatar_url"), + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in assignee_distribution + ] + + # Get the label distribution + label_distribution = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + # Label distribution serialization + label_distribution_data = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": (str(item["label_id"]) if item["label_id"] else None), + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in label_distribution + ] + + # Generate completion chart + completion_chart = burndown_plot( + queryset=old_cycle, + slug=slug, + project_id=project_id, + plot_type="issues", + cycle_id=cycle_id, + ) + + # Get the current cycle and save progress snapshot + current_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ).first() + + current_cycle.progress_snapshot = { + "total_issues": old_cycle.total_issues, + "completed_issues": old_cycle.completed_issues, + "cancelled_issues": old_cycle.cancelled_issues, + "started_issues": old_cycle.started_issues, + "unstarted_issues": old_cycle.unstarted_issues, + "backlog_issues": old_cycle.backlog_issues, + "distribution": { + "labels": label_distribution_data, + "assignees": assignee_distribution_data, + "completion_chart": completion_chart, + }, + "estimate_distribution": ( + {} + if not estimate_type + else { + "labels": label_estimate_distribution, + "assignees": assignee_estimate_distribution, + "completion_chart": estimate_completion_chart, + } + ), + } + current_cycle.save(update_fields=["progress_snapshot"]) + + # Get issues to transfer (only incomplete issues) + cycle_issues = CycleIssue.objects.filter( + cycle_id=cycle_id, + project_id=project_id, + workspace__slug=slug, + issue__archived_at__isnull=True, + issue__is_draft=False, + issue__state__group__in=["backlog", "unstarted", "started"], + ) + + updated_cycles = [] + update_cycle_issue_activity = [] + for cycle_issue in cycle_issues: + cycle_issue.cycle_id = new_cycle_id + updated_cycles.append(cycle_issue) + update_cycle_issue_activity.append( + { + "old_cycle_id": str(cycle_id), + "new_cycle_id": str(new_cycle_id), + "issue_id": str(cycle_issue.issue_id), + } + ) + + # Bulk update cycle issues + cycle_issues = CycleIssue.objects.bulk_update( + updated_cycles, ["cycle_id"], batch_size=100 + ) + + # Capture Issue Activity + issue_activity.delay( + type="cycle.activity.created", + requested_data=json.dumps({"cycles_list": []}), + actor_id=str(user_id), + issue_id=None, + project_id=str(project_id), + current_instance=json.dumps( + { + "updated_cycle_issues": update_cycle_issue_activity, + "created_cycle_issues": [], + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + + return {"success": True} diff --git a/apps/api/plane/utils/exporters/README.md b/apps/api/plane/utils/exporters/README.md new file mode 100644 index 0000000000..cbecaaa4b9 --- /dev/null +++ b/apps/api/plane/utils/exporters/README.md @@ -0,0 +1,496 @@ +# 📊 Exporters + +A flexible and extensible data export utility for exporting Django model data in multiple formats (CSV, JSON, XLSX). + +## 🎯 Overview + +The exporters module provides a schema-based approach to exporting data with support for: + +- **📄 Multiple formats**: CSV, JSON, and XLSX (Excel) +- **🔒 Type-safe field definitions**: StringField, NumberField, DateField, DateTimeField, BooleanField, ListField, JSONField +- **⚡ Custom transformations**: Field-level transformations and custom preparer methods +- **🔗 Dotted path notation**: Easy access to nested attributes and related models +- **🎨 Format-specific handling**: Automatic formatting based on export format (e.g., lists as arrays in JSON, comma-separated in CSV) + +## 🚀 Quick Start + +### Basic Usage + +```python +from plane.utils.exporters import Exporter, ExportSchema, StringField, NumberField + +# Define a schema +class UserExportSchema(ExportSchema): + name = StringField(source="username", label="User Name") + email = StringField(source="email", label="Email Address") + posts_count = NumberField(label="Total Posts") + + def prepare_posts_count(self, obj): + return obj.posts.count() + +# Export data - just pass the queryset! +users = User.objects.all() +exporter = Exporter(format_type="csv", schema_class=UserExportSchema) +filename, content = exporter.export("users_export", users) +``` + +### Exporting Issues + +```python +from plane.utils.exporters import Exporter, IssueExportSchema + +# Get issues with prefetched relations +issues = Issue.objects.filter(project_id=project_id).prefetch_related( + 'assignee_details', + 'label_details', + 'issue_module', + # ... other relations +) + +# Export as XLSX - pass the queryset directly! +exporter = Exporter(format_type="xlsx", schema_class=IssueExportSchema) +filename, content = exporter.export("issues", issues) + +# Export with custom fields only +exporter = Exporter(format_type="json", schema_class=IssueExportSchema) +filename, content = exporter.export("issues_filtered", issues, fields=["id", "name", "state_name", "assignees"]) +``` + +### Exporting Multiple Projects Separately + +```python +# Export each project to a separate file +for project_id in project_ids: + project_issues = issues.filter(project_id=project_id) + exporter = Exporter(format_type="csv", schema_class=IssueExportSchema) + filename, content = exporter.export(f"issues-{project_id}", project_issues) + # Save or upload the file +``` + +## 📝 Schema Definition + +### Field Types + +#### 📝 StringField + +Converts values to strings. + +```python +name = StringField(source="name", label="Name", default="N/A") +``` + +#### 🔢 NumberField + +Handles numeric values (int, float). + +```python +count = NumberField(source="items_count", label="Count", default=0) +``` + +#### 📅 DateField + +Formats date objects as `%a, %d %b %Y` (e.g., "Mon, 01 Jan 2024"). + +```python +start_date = DateField(source="start_date", label="Start Date") +``` + +#### ⏰ DateTimeField + +Formats datetime objects as `%a, %d %b %Y %I:%M:%S %Z%z`. + +```python +created_at = DateTimeField(source="created_at", label="Created At") +``` + +#### ✅ BooleanField + +Converts values to boolean. + +```python +is_active = BooleanField(source="is_active", label="Active", default=False) +``` + +#### 📋 ListField + +Handles list/array values. In CSV/XLSX, lists are joined with a separator (default: `", "`). In JSON, they remain as arrays. + +```python +tags = ListField(source="tags", label="Tags") +assignees = ListField(label="Assignees") # Custom preparer can populate this +``` + +#### 🗂️ JSONField + +Handles complex JSON-serializable objects (dicts, lists of dicts). In CSV/XLSX, they're serialized as JSON strings. In JSON, they remain as objects. + +```python +metadata = JSONField(source="metadata", label="Metadata") +comments = JSONField(label="Comments") +``` + +### ⚙️ Field Parameters + +All field types support these parameters: + +- **`source`**: Dotted path string to the attribute (e.g., `"project.name"`) +- **`default`**: Default value when field is None +- **`label`**: Display name in export headers + +### 🔗 Dotted Path Notation + +Access nested attributes using dot notation: + +```python +project_name = StringField(source="project.name", label="Project") +owner_email = StringField(source="created_by.email", label="Owner Email") +``` + +### 🎯 Custom Preparers + +For complex logic, define `prepare_{field_name}` methods: + +```python +class MySchema(ExportSchema): + assignees = ListField(label="Assignees") + + def prepare_assignees(self, obj): + return [f"{u.first_name} {u.last_name}" for u in obj.assignee_details] +``` + +Preparers take precedence over field definitions. + +### ⚡ Custom Transformations with Preparer Methods + +For any custom logic or transformations, use `prepare_` methods: + +```python +class MySchema(ExportSchema): + name = StringField(source="name", label="Name (Uppercase)") + status = StringField(label="Status") + + def prepare_name(self, obj): + """Transform the name field to uppercase.""" + return obj.name.upper() if obj.name else "" + + def prepare_status(self, obj): + """Compute status based on model state.""" + return "Active" if obj.is_active else "Inactive" +``` + +## 📦 Export Formats + +### 📊 CSV Format + +- Fields are quoted with `QUOTE_ALL` +- Lists are joined with `", "` (customizable with `list_joiner` option) +- JSON objects are serialized as JSON strings +- File extension: `.csv` + +```python +exporter = Exporter( + format_type="csv", + schema_class=MySchema, + options={"list_joiner": "; "} # Custom separator +) +``` + +### 📋 JSON Format + +- Lists remain as arrays +- Objects remain as nested structures +- Preserves data types +- File extension: `.json` + +```python +exporter = Exporter(format_type="json", schema_class=MySchema) +filename, content = exporter.export("data", records) +# content is a JSON string: '[{"field": "value"}, ...]' +``` + +### 📗 XLSX Format + +- Creates Excel-compatible files using openpyxl +- Lists are joined with `", "` (customizable with `list_joiner` option) +- JSON objects are serialized as JSON strings +- File extension: `.xlsx` +- Returns binary content (bytes) + +```python +exporter = Exporter(format_type="xlsx", schema_class=MySchema) +filename, content = exporter.export("data", records) +# content is bytes +``` + +## 🔧 Advanced Usage + +### 📦 Using Context for Pre-fetched Data + +Pass context data to schemas to avoid N+1 queries. Override `get_context_data()` in your schema: + +```python +class MySchema(ExportSchema): + attachment_count = NumberField(label="Attachments") + + def prepare_attachment_count(self, obj): + attachments_dict = self.context.get("attachments_dict", {}) + return len(attachments_dict.get(obj.id, [])) + + @classmethod + def get_context_data(cls, queryset): + """Pre-fetch all attachments in one query.""" + attachments_dict = get_attachments_dict(queryset) + return {"attachments_dict": attachments_dict} + +# The Exporter automatically uses get_context_data() when serializing +queryset = MyModel.objects.all() +exporter = Exporter(format_type="csv", schema_class=MySchema) +filename, content = exporter.export("data", queryset) +``` + +### 🔌 Registering Custom Formatters + +Add support for new export formats: + +```python +from plane.utils.exporters import Exporter, BaseFormatter + +class XMLFormatter(BaseFormatter): + def format(self, filename, records, schema_class, options=None): + # Implementation + return (f"{filename}.xml", xml_content) + +# Register the formatter +Exporter.register_formatter("xml", XMLFormatter) + +# Use it +exporter = Exporter(format_type="xml", schema_class=MySchema) +``` + +### ✅ Checking Available Formats + +```python +formats = Exporter.get_available_formats() +# Returns: ['csv', 'json', 'xlsx'] +``` + +### 🔍 Filtering Fields + +Pass a `fields` parameter to export only specific fields: + +```python +# Export only specific fields +exporter = Exporter(format_type="csv", schema_class=MySchema) +filename, content = exporter.export( + "filtered_data", + queryset, + fields=["id", "name", "email"] +) +``` + +### 🎯 Extending Schemas + +Create extended schemas by inheriting from existing ones and overriding `get_context_data()`: + +```python +class ExtendedIssueExportSchema(IssueExportSchema): + custom_field = JSONField(label="Custom Data") + + def prepare_custom_field(self, obj): + # Use pre-fetched data from context + return self.context.get("custom_data", {}).get(obj.id, {}) + + @classmethod + def get_context_data(cls, queryset): + # Get parent context (attachments, etc.) + context = super().get_context_data(queryset) + + # Add your custom pre-fetched data + context["custom_data"] = fetch_custom_data(queryset) + + return context +``` + +### 💾 Manual Serialization + +If you need to serialize data without exporting, you can use the schema directly: + +```python +# Serialize a queryset to a list of dicts +data = MySchema.serialize_queryset(queryset, fields=["id", "name"]) + +# Or serialize a single object +schema = MySchema() +obj_data = schema.serialize(obj) +``` + +## 💡 Example: IssueExportSchema + +The `IssueExportSchema` demonstrates a complete implementation: + +```python +from plane.utils.exporters import Exporter, IssueExportSchema + +# Simple export - just pass the queryset! +issues = Issue.objects.filter(project_id=project_id) +exporter = Exporter(format_type="csv", schema_class=IssueExportSchema) +filename, content = exporter.export("issues", issues) + +# Export specific fields only +filename, content = exporter.export( + "issues_filtered", + issues, + fields=["id", "name", "state_name", "assignees", "labels"] +) + +# Export multiple projects to separate files +for project_id in project_ids: + project_issues = issues.filter(project_id=project_id) + filename, content = exporter.export(f"issues-{project_id}", project_issues) + # Save or upload each file +``` + +Key features: + +- 🔗 Access to related models via dotted paths +- 🎯 Custom preparers for complex fields +- 📎 Context-based attachment handling via `get_context_data()` +- 📋 List and JSON field handling +- 📅 Date/datetime formatting + +## ✨ Best Practices + +1. **🚄 Avoid N+1 Queries**: Override `get_context_data()` to pre-fetch related data: + + ```python + @classmethod + def get_context_data(cls, queryset): + return { + "attachments": get_attachments_dict(queryset), + "comments": get_comments_dict(queryset), + } + ``` + +2. **🏷️ Use Labels**: Provide descriptive labels for better export headers: + + ```python + created_at = DateTimeField(source="created_at", label="Created At") + ``` + +3. **🛡️ Handle None Values**: Set appropriate defaults for fields that might be None: + + ```python + count = NumberField(source="count", default=0) + ``` + +4. **🎯 Use Preparers for Complex Logic**: Keep field definitions simple and use preparers for complex transformations: + + ```python + def prepare_assignees(self, obj): + return [f"{u.first_name} {u.last_name}" for u in obj.assignee_details] + ``` + +5. **⚡ Pass QuerySets Directly**: Let the Exporter handle serialization: + + ```python + # Good - Exporter handles serialization + exporter.export("data", queryset) + + # Avoid - Manual serialization unless needed + data = MySchema.serialize_queryset(queryset) + exporter.export("data", data) + ``` + +6. **📦 Filter QuerySets, Not Data**: For multiple exports, filter the queryset instead of the serialized data: + + ```python + # Good - efficient, only serializes what's needed + for project_id in project_ids: + project_issues = issues.filter(project_id=project_id) + exporter.export(f"project-{project_id}", project_issues) + + # Avoid - serializes all data upfront + all_data = MySchema.serialize_queryset(issues) + for project_id in project_ids: + project_data = [d for d in all_data if d['project_id'] == project_id] + exporter.export(f"project-{project_id}", project_data) + ``` + +## 📚 API Reference + +### 📊 Exporter + +**`__init__(format_type, schema_class, options=None)`** + +- `format_type`: Export format ('csv', 'json', 'xlsx') +- `schema_class`: Schema class defining fields +- `options`: Optional dict of format-specific options + +**`export(filename, data, fields=None)`** + +- `filename`: Filename without extension +- `data`: Django QuerySet or list of dicts +- `fields`: Optional list of field names to include +- Returns: `(filename_with_extension, content)` +- `content` is str for CSV/JSON, bytes for XLSX + +**`get_available_formats()`** (class method) + +- Returns: List of available format types + +**`register_formatter(format_type, formatter_class)`** (class method) + +- Register a custom formatter + +### 📝 ExportSchema + +**`__init__(context=None)`** + +- `context`: Optional dict accessible in preparer methods via `self.context` for pre-fetched data + +**`serialize(obj, fields=None)`** + +- Returns: Dict of serialized field values for a single object + +**`serialize_queryset(queryset, fields=None)`** (class method) + +- `queryset`: QuerySet of objects to serialize +- `fields`: Optional list of field names to include +- Returns: List of dicts with serialized data + +**`get_context_data(queryset)`** (class method) + +- Override to pre-fetch related data for the queryset +- Returns: Dict of context data + +### 🔧 ExportField + +Base class for all field types. Subclass to create custom field types. + +**`get_value(obj, context)`** + +- Returns: Formatted value for the field + +**`_format_value(raw)`** + +- Override in subclasses for type-specific formatting + +## 🧪 Testing + +```python +# Test exporting a queryset +queryset = MyModel.objects.all() +exporter = Exporter(format_type="json", schema_class=MySchema) +filename, content = exporter.export("test", queryset) +assert filename == "test.json" +assert isinstance(content, str) + +# Test with field filtering +filename, content = exporter.export("test", queryset, fields=["id", "name"]) +data = json.loads(content) +assert all(set(item.keys()) == {"id", "name"} for item in data) + +# Test manual serialization +data = MySchema.serialize_queryset(queryset) +assert len(data) == queryset.count() +``` diff --git a/apps/api/plane/utils/exporters/__init__.py b/apps/api/plane/utils/exporters/__init__.py new file mode 100644 index 0000000000..9e7b1a9d51 --- /dev/null +++ b/apps/api/plane/utils/exporters/__init__.py @@ -0,0 +1,38 @@ +"""Export utilities for various data formats.""" + +from .exporter import Exporter +from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter +from .schemas import ( + BooleanField, + DateField, + DateTimeField, + ExportField, + ExportSchema, + IssueExportSchema, + JSONField, + ListField, + NumberField, + StringField, +) + +__all__ = [ + # Core Exporter + "Exporter", + # Schemas + "ExportSchema", + "ExportField", + "StringField", + "NumberField", + "DateField", + "DateTimeField", + "BooleanField", + "ListField", + "JSONField", + # Formatters + "BaseFormatter", + "CSVFormatter", + "JSONFormatter", + "XLSXFormatter", + # Issue Schema + "IssueExportSchema", +] diff --git a/apps/api/plane/utils/exporters/exporter.py b/apps/api/plane/utils/exporters/exporter.py new file mode 100644 index 0000000000..75b396cb4e --- /dev/null +++ b/apps/api/plane/utils/exporters/exporter.py @@ -0,0 +1,72 @@ +from typing import Any, Dict, List, Type, Union + +from django.db.models import QuerySet + +from .formatters import CSVFormatter, JSONFormatter, XLSXFormatter + + +class Exporter: + """Generic exporter class that handles data exports using different formatters.""" + + # Available formatters + FORMATTERS = { + "csv": CSVFormatter, + "json": JSONFormatter, + "xlsx": XLSXFormatter, + } + + def __init__(self, format_type: str, schema_class: Type, options: Dict[str, Any] = None): + """Initialize exporter with specified format type and schema. + + Args: + format_type: The export format (csv, json, xlsx) + schema_class: The schema class to use for field definitions + options: Optional formatting options + """ + if format_type not in self.FORMATTERS: + raise ValueError(f"Unsupported format: {format_type}. Available: {list(self.FORMATTERS.keys())}") + + self.format_type = format_type + self.schema_class = schema_class + self.formatter = self.FORMATTERS[format_type]() + self.options = options or {} + + def export( + self, + filename: str, + data: Union[QuerySet, List[dict]], + fields: List[str] = None, + ) -> tuple[str, str | bytes]: + """Export data using the configured formatter and return (filename, content). + + Args: + filename: The filename for the export (without extension) + data: Either a Django QuerySet or a list of already-serialized dicts + fields: Optional list of field names to include in export + + Returns: + Tuple of (filename_with_extension, content) + """ + # Serialize the queryset if needed + if isinstance(data, QuerySet): + records = self.schema_class.serialize_queryset(data, fields=fields) + else: + # Already serialized data + records = data + + # Merge fields into options for the formatter + format_options = {**self.options} + if fields: + format_options["fields"] = fields + + return self.formatter.format(filename, records, self.schema_class, format_options) + + @classmethod + def get_available_formats(cls) -> List[str]: + """Get list of available export formats.""" + return list(cls.FORMATTERS.keys()) + + @classmethod + def register_formatter(cls, format_type: str, formatter_class: type) -> None: + """Register a new formatter for a format type.""" + cls.FORMATTERS[format_type] = formatter_class diff --git a/apps/api/plane/utils/exporters/formatters.py b/apps/api/plane/utils/exporters/formatters.py new file mode 100644 index 0000000000..fc7c23528b --- /dev/null +++ b/apps/api/plane/utils/exporters/formatters.py @@ -0,0 +1,199 @@ +import csv +import io +import json +from typing import Any, Dict, List, Type + +from openpyxl import Workbook + + +class BaseFormatter: + """Base class for export formatters.""" + + def format( + self, + filename: str, + records: List[dict], + schema_class: Type, + options: Dict[str, Any] | None = None, + ) -> tuple[str, str | bytes]: + """Format records for export. + + Args: + filename: The filename for the export (without extension) + records: List of records to export + schema_class: Schema class to extract field order and labels + options: Optional formatting options + + Returns: + Tuple of (filename_with_extension, content) + """ + raise NotImplementedError + + @staticmethod + def _get_field_info(schema_class: Type) -> tuple[List[str], Dict[str, str]]: + """Extract field order and labels from schema. + + Args: + schema_class: Schema class with field definitions + + Returns: + Tuple of (field_order, field_labels) + """ + if not hasattr(schema_class, "_declared_fields"): + raise ValueError(f"Schema class {schema_class.__name__} must have _declared_fields attribute") + + # Get order and labels from schema + field_order = list(schema_class._declared_fields.keys()) + field_labels = { + name: field.label if field.label else name.replace("_", " ").title() + for name, field in schema_class._declared_fields.items() + } + + return field_order, field_labels + + +class CSVFormatter(BaseFormatter): + """Formatter for CSV exports.""" + + @staticmethod + def _format_field_value(value: Any, list_joiner: str = ", ") -> str: + """Format a field value for CSV output.""" + if value is None: + return "" + if isinstance(value, list): + return list_joiner.join(str(v) for v in value) + if isinstance(value, dict): + # For complex objects, serialize as JSON + return json.dumps(value) + return str(value) + + def _generate_table_row( + self, record: dict, field_order: List[str], options: Dict[str, Any] | None = None + ) -> List[str]: + """Generate a CSV row from a record.""" + opts = options or {} + list_joiner = opts.get("list_joiner", ", ") + return [self._format_field_value(record.get(field, ""), list_joiner) for field in field_order] + + def _create_csv_file(self, data: List[List[str]]) -> str: + """Create CSV file content from row data.""" + buf = io.StringIO() + writer = csv.writer(buf, delimiter=",", quoting=csv.QUOTE_ALL) + for row in data: + writer.writerow(row) + buf.seek(0) + return buf.getvalue() + + def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, str]: + if not records: + return (f"{filename}.csv", "") + + # Get field order and labels from schema + field_order, field_labels = self._get_field_info(schema_class) + + # Filter to requested fields if specified + opts = options or {} + requested_fields = opts.get("fields") + if requested_fields: + field_order = [f for f in field_order if f in requested_fields] + + header = [field_labels[field] for field in field_order] + + rows = [header] + for record in records: + row = self._generate_table_row(record, field_order, options) + rows.append(row) + content = self._create_csv_file(rows) + return (f"{filename}.csv", content) + + +class JSONFormatter(BaseFormatter): + """Formatter for JSON exports.""" + + def _generate_json_row( + self, record: dict, field_labels: Dict[str, str], field_order: List[str], options: Dict[str, Any] | None = None + ) -> dict: + """Generate a JSON object from a record. + + Preserves data types - lists stay as arrays, dicts stay as objects. + """ + return {field_labels[field]: record.get(field) for field in field_order if field in record} + + def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, str]: + if not records: + return (f"{filename}.json", "[]") + + # Get field order and labels from schema + field_order, field_labels = self._get_field_info(schema_class) + + # Filter to requested fields if specified + opts = options or {} + requested_fields = opts.get("fields") + if requested_fields: + field_order = [f for f in field_order if f in requested_fields] + + rows: List[dict] = [] + for record in records: + row = self._generate_json_row(record, field_labels, field_order, options) + rows.append(row) + content = json.dumps(rows) + return (f"{filename}.json", content) + + +class XLSXFormatter(BaseFormatter): + """Formatter for XLSX (Excel) exports.""" + + @staticmethod + def _format_field_value(value: Any, list_joiner: str = ", ") -> str: + """Format a field value for XLSX output.""" + if value is None: + return "" + if isinstance(value, list): + return list_joiner.join(str(v) for v in value) + if isinstance(value, dict): + # For complex objects, serialize as JSON + return json.dumps(value) + return str(value) + + def _generate_table_row( + self, record: dict, field_order: List[str], options: Dict[str, Any] | None = None + ) -> List[str]: + """Generate an XLSX row from a record.""" + opts = options or {} + list_joiner = opts.get("list_joiner", ", ") + return [self._format_field_value(record.get(field, ""), list_joiner) for field in field_order] + + def _create_xlsx_file(self, data: List[List[str]]) -> bytes: + """Create XLSX file content from row data.""" + wb = Workbook() + sh = wb.active + for row in data: + sh.append(row) + out = io.BytesIO() + wb.save(out) + out.seek(0) + return out.getvalue() + + def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, bytes]: + if not records: + # Create empty workbook + content = self._create_xlsx_file([]) + return (f"{filename}.xlsx", content) + + # Get field order and labels from schema + field_order, field_labels = self._get_field_info(schema_class) + + # Filter to requested fields if specified + opts = options or {} + requested_fields = opts.get("fields") + if requested_fields: + field_order = [f for f in field_order if f in requested_fields] + + header = [field_labels[field] for field in field_order] + + rows = [header] + for record in records: + row = self._generate_table_row(record, field_order, options) + rows.append(row) + content = self._create_xlsx_file(rows) + return (f"{filename}.xlsx", content) diff --git a/apps/api/plane/utils/exporters/schemas/__init__.py b/apps/api/plane/utils/exporters/schemas/__init__.py new file mode 100644 index 0000000000..98b2623aed --- /dev/null +++ b/apps/api/plane/utils/exporters/schemas/__init__.py @@ -0,0 +1,30 @@ +"""Export schemas for various data types.""" + +from .base import ( + BooleanField, + DateField, + DateTimeField, + ExportField, + ExportSchema, + JSONField, + ListField, + NumberField, + StringField, +) +from .issue import IssueExportSchema + +__all__ = [ + # Base field types + "ExportField", + "StringField", + "NumberField", + "DateField", + "DateTimeField", + "BooleanField", + "ListField", + "JSONField", + # Base schema + "ExportSchema", + # Issue schema + "IssueExportSchema", +] diff --git a/apps/api/plane/utils/exporters/schemas/base.py b/apps/api/plane/utils/exporters/schemas/base.py new file mode 100644 index 0000000000..4e67c6980c --- /dev/null +++ b/apps/api/plane/utils/exporters/schemas/base.py @@ -0,0 +1,234 @@ +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from django.db.models import QuerySet + + +@dataclass +class ExportField: + """Base export field class for generic fields.""" + + source: Optional[str] = None + default: Any = "" + label: Optional[str] = None # Display name for export headers + + def get_value(self, obj: Any, context: Dict[str, Any]) -> Any: + raw: Any + if self.source: + raw = self._resolve_dotted_path(obj, self.source) + else: + raw = obj + + return self._format_value(raw) + + def _format_value(self, raw: Any) -> Any: + """Format the raw value. Override in subclasses for type-specific formatting.""" + return raw if raw is not None else self.default + + def _resolve_dotted_path(self, obj: Any, path: str) -> Any: + current = obj + for part in path.split("."): + if current is None: + return None + if hasattr(current, part): + current = getattr(current, part) + elif isinstance(current, dict): + current = current.get(part) + else: + return None + return current + + +@dataclass +class StringField(ExportField): + """Export field for string values.""" + + default: str = "" + + def _format_value(self, raw: Any) -> str: + if raw is None: + return self.default + return str(raw) + + +@dataclass +class DateField(ExportField): + """Export field for date values with automatic conversion.""" + + default: str = "" + + def _format_value(self, raw: Any) -> str: + if raw is None: + return self.default + # Convert date to formatted string + if hasattr(raw, "strftime"): + return raw.strftime("%a, %d %b %Y") + return str(raw) + + +@dataclass +class DateTimeField(ExportField): + """Export field for datetime values with automatic conversion.""" + + default: str = "" + + def _format_value(self, raw: Any) -> str: + if raw is None: + return self.default + # Convert datetime to formatted string + if hasattr(raw, "strftime"): + return raw.strftime("%a, %d %b %Y %I:%M:%S %Z%z") + return str(raw) + + +@dataclass +class NumberField(ExportField): + """Export field for numeric values.""" + + default: Any = "" + + def _format_value(self, raw: Any) -> Any: + if raw is None: + return self.default + return raw + + +@dataclass +class BooleanField(ExportField): + """Export field for boolean values.""" + + default: bool = False + + def _format_value(self, raw: Any) -> bool: + if raw is None: + return self.default + return bool(raw) + + +@dataclass +class ListField(ExportField): + """Export field for list/array values. + + Returns the list as-is by default. The formatter will handle conversion to strings + when needed (e.g., CSV/XLSX will join with separator, JSON will keep as array). + """ + + default: Optional[List] = field(default_factory=list) + + def _format_value(self, raw: Any) -> List[Any]: + if raw is None: + return self.default if self.default is not None else [] + if isinstance(raw, (list, tuple)): + return list(raw) + return [raw] # Wrap single items in a list + + +@dataclass +class JSONField(ExportField): + """Export field for complex JSON-serializable values (dicts, lists of dicts, etc). + + Preserves the structure as-is for JSON exports. For CSV/XLSX, the formatter + will handle serialization (e.g., JSON stringify). + """ + + default: Any = field(default_factory=dict) + + def _format_value(self, raw: Any) -> Any: + if raw is None: + return self.default + # Return as-is - should be JSON-serializable + return raw + + +class ExportSchemaMeta(type): + def __new__(mcls, name, bases, attrs): + declared: Dict[str, ExportField] = { + key: value for key, value in list(attrs.items()) if isinstance(value, ExportField) + } + for key in declared.keys(): + attrs.pop(key) + cls = super().__new__(mcls, name, bases, attrs) + base_fields: Dict[str, ExportField] = {} + for base in bases: + if hasattr(base, "_declared_fields"): + base_fields.update(base._declared_fields) + base_fields.update(declared) + cls._declared_fields = base_fields + return cls + + +class ExportSchema(metaclass=ExportSchemaMeta): + """Base schema for exporting data in various formats. + + Subclasses should define fields as class attributes and can override: + - prepare_ methods for custom field serialization + - get_context_data() class method to pre-fetch related data for the queryset + """ + + def __init__(self, context: Optional[Dict[str, Any]] = None) -> None: + self.context = context or {} + + def serialize(self, obj: Any, fields: Optional[List[str]] = None) -> Dict[str, Any]: + """Serialize a single object. + + Args: + obj: The object to serialize + fields: Optional list of field names to include. If None, all fields are serialized. + + Returns: + Dictionary of serialized data + """ + output: Dict[str, Any] = {} + # Determine which fields to process + fields_to_process = fields if fields else list(self._declared_fields.keys()) + + for field_name in fields_to_process: + # Skip if field doesn't exist in schema + if field_name not in self._declared_fields: + continue + + export_field = self._declared_fields[field_name] + + # Prefer explicit preparer methods if present + preparer = getattr(self, f"prepare_{field_name}", None) + if callable(preparer): + output[field_name] = preparer(obj) + continue + + output[field_name] = export_field.get_value(obj, self.context) + return output + + @classmethod + def get_context_data(cls, queryset: QuerySet) -> Dict[str, Any]: + """Get context data for serialization. Override in subclasses to pre-fetch related data. + + Args: + queryset: QuerySet of objects to be serialized + + Returns: + Dictionary of context data to be passed to the schema instance + """ + return {} + + @classmethod + def serialize_queryset(cls, queryset: QuerySet, fields: List[str] = None) -> List[Dict[str, Any]]: + """Serialize a queryset of objects to export data. + + Args: + queryset: QuerySet of objects to serialize + fields: Optional list of field names to include. Defaults to all fields. + + Returns: + List of dictionaries containing serialized data + """ + # Get context data (can be extended by subclasses) + context = cls.get_context_data(queryset) + + # Serialize each object, passing fields to only process requested fields + schema = cls(context=context) + data = [] + for obj in queryset: + obj_data = schema.serialize(obj, fields=fields) + data.append(obj_data) + + return data diff --git a/apps/api/plane/utils/exporters/schemas/issue.py b/apps/api/plane/utils/exporters/schemas/issue.py new file mode 100644 index 0000000000..744e330524 --- /dev/null +++ b/apps/api/plane/utils/exporters/schemas/issue.py @@ -0,0 +1,210 @@ +from collections import defaultdict +from typing import Any, Dict, List, Optional + +from django.db.models import F, QuerySet + +from plane.db.models import CycleIssue, FileAsset + +from .base import ( + DateField, + DateTimeField, + ExportSchema, + JSONField, + ListField, + NumberField, + StringField, +) + + +def get_issue_attachments_dict(issues_queryset: QuerySet) -> Dict[str, List[str]]: + """Get attachments dictionary for the given issues queryset. + + Args: + issues_queryset: Queryset of Issue objects + + Returns: + Dictionary mapping issue IDs to lists of attachment IDs + """ + file_assets = FileAsset.objects.filter( + issue_id__in=issues_queryset.values_list("id", flat=True), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ).annotate(work_item_id=F("issue_id"), asset_id=F("id")) + + attachment_dict = defaultdict(list) + for asset in file_assets: + attachment_dict[asset.work_item_id].append(asset.asset_id) + + return attachment_dict + + +def get_issue_last_cycles_dict(issues_queryset: QuerySet) -> Dict[str, Optional[CycleIssue]]: + """Get the last cycle for each issue in the given queryset. + + Args: + issues_queryset: Queryset of Issue objects + + Returns: + Dictionary mapping issue IDs to their last CycleIssue object + """ + # Fetch all cycle issues for the given issues, ordered by created_at descending + # select_related is used to fetch cycle data in the same query + cycle_issues = ( + CycleIssue.objects.filter(issue_id__in=issues_queryset.values_list("id", flat=True)) + .select_related("cycle") + .order_by("issue_id", "-created_at") + ) + + # Keep only the last (most recent) cycle for each issue + last_cycles_dict = {} + for cycle_issue in cycle_issues: + if cycle_issue.issue_id not in last_cycles_dict: + last_cycles_dict[cycle_issue.issue_id] = cycle_issue + + return last_cycles_dict + + +class IssueExportSchema(ExportSchema): + """Schema for exporting issue data in various formats.""" + + @staticmethod + def _get_created_by(obj) -> str: + """Get the created by user for the given object.""" + try: + if getattr(obj, "created_by", None): + return f"{obj.created_by.first_name} {obj.created_by.last_name}" + except Exception: + pass + return "" + + @staticmethod + def _format_date(date_obj) -> str: + """Format date object to string.""" + if date_obj and hasattr(date_obj, "strftime"): + return date_obj.strftime("%a, %d %b %Y") + return "" + + # Field definitions with display labels + id = StringField(label="ID") + project_identifier = StringField(source="project.identifier", label="Project Identifier") + project_name = StringField(source="project.name", label="Project") + project_id = StringField(source="project.id", label="Project ID") + sequence_id = NumberField(source="sequence_id", label="Sequence ID") + name = StringField(source="name", label="Name") + description = StringField(source="description_stripped", label="Description") + priority = StringField(source="priority", label="Priority") + start_date = DateField(source="start_date", label="Start Date") + target_date = DateField(source="target_date", label="Target Date") + state_name = StringField(label="State") + created_at = DateTimeField(source="created_at", label="Created At") + updated_at = DateTimeField(source="updated_at", label="Updated At") + completed_at = DateTimeField(source="completed_at", label="Completed At") + archived_at = DateTimeField(source="archived_at", label="Archived At") + module_name = ListField(label="Module Name") + created_by = StringField(label="Created By") + labels = ListField(label="Labels") + comments = JSONField(label="Comments") + estimate = StringField(label="Estimate") + link = ListField(label="Link") + assignees = ListField(label="Assignees") + subscribers_count = NumberField(label="Subscribers Count") + attachment_count = NumberField(label="Attachment Count") + attachment_links = ListField(label="Attachment Links") + cycle_name = StringField(label="Cycle Name") + cycle_start_date = DateField(label="Cycle Start Date") + cycle_end_date = DateField(label="Cycle End Date") + parent = StringField(label="Parent") + relations = JSONField(label="Relations") + + def prepare_id(self, i): + return f"{i.project.identifier}-{i.sequence_id}" + + def prepare_state_name(self, i): + return i.state.name if i.state else None + + def prepare_module_name(self, i): + return [m.module.name for m in i.issue_module.all()] + + def prepare_created_by(self, i): + return self._get_created_by(i) + + def prepare_labels(self, i): + return [label.name for label in i.labels.all()] + + def prepare_comments(self, i): + return [ + { + "comment": comment.comment_stripped, + "created_at": self._format_date(comment.created_at), + "created_by": self._get_created_by(comment), + } + for comment in i.issue_comments.all() + ] + + def prepare_estimate(self, i): + return i.estimate_point.value if i.estimate_point and i.estimate_point.value else "" + + def prepare_link(self, i): + return [link.url for link in i.issue_link.all()] + + def prepare_assignees(self, i): + return [f"{u.first_name} {u.last_name}" for u in i.assignees.all()] + + def prepare_subscribers_count(self, i): + return i.issue_subscribers.count() + + def prepare_attachment_count(self, i): + return len((self.context.get("attachments_dict") or {}).get(i.id, [])) + + def prepare_attachment_links(self, i): + return [ + f"/api/assets/v2/workspaces/{i.workspace.slug}/projects/{i.project_id}/issues/{i.id}/attachments/{asset}/" + for asset in (self.context.get("attachments_dict") or {}).get(i.id, []) + ] + + def prepare_cycle_name(self, i): + cycles_dict = self.context.get("cycles_dict") or {} + last_cycle = cycles_dict.get(i.id) + return last_cycle.cycle.name if last_cycle else "" + + def prepare_cycle_start_date(self, i): + cycles_dict = self.context.get("cycles_dict") or {} + last_cycle = cycles_dict.get(i.id) + if last_cycle and last_cycle.cycle.start_date: + return self._format_date(last_cycle.cycle.start_date) + return "" + + def prepare_cycle_end_date(self, i): + cycles_dict = self.context.get("cycles_dict") or {} + last_cycle = cycles_dict.get(i.id) + if last_cycle and last_cycle.cycle.end_date: + return self._format_date(last_cycle.cycle.end_date) + return "" + + def prepare_parent(self, i): + if not i.parent: + return "" + return f"{i.parent.project.identifier}-{i.parent.sequence_id}" + + def prepare_relations(self, i): + # Should show reverse relation as well + from plane.db.models.issue import IssueRelationChoices + + relations = { + r.relation_type: f"{r.related_issue.project.identifier}-{r.related_issue.sequence_id}" + for r in i.issue_relation.all() + } + reverse_relations = {} + for relation in i.issue_related.all(): + reverse_relations[IssueRelationChoices._REVERSE_MAPPING[relation.relation_type]] = ( + f"{relation.issue.project.identifier}-{relation.issue.sequence_id}" + ) + relations.update(reverse_relations) + return relations + + @classmethod + def get_context_data(cls, queryset: QuerySet) -> Dict[str, Any]: + """Get context data for issue serialization.""" + return { + "attachments_dict": get_issue_attachments_dict(queryset), + "cycles_dict": get_issue_last_cycles_dict(queryset), + } diff --git a/apps/api/plane/utils/instance_config_variables/__init__.py b/apps/api/plane/utils/instance_config_variables/__init__.py new file mode 100644 index 0000000000..6818ca9bf7 --- /dev/null +++ b/apps/api/plane/utils/instance_config_variables/__init__.py @@ -0,0 +1,4 @@ +from .core import core_config_variables +from .extended import extended_config_variables + +instance_config_variables = [*core_config_variables, *extended_config_variables] diff --git a/apps/api/plane/utils/instance_config_variables/core.py b/apps/api/plane/utils/instance_config_variables/core.py new file mode 100644 index 0000000000..cf8d8d41fb --- /dev/null +++ b/apps/api/plane/utils/instance_config_variables/core.py @@ -0,0 +1,233 @@ +# Python imports +import os + +authentication_config_variables = [ + { + "key": "ENABLE_SIGNUP", + "value": os.environ.get("ENABLE_SIGNUP", "1"), + "category": "AUTHENTICATION", + "is_encrypted": False, + }, + { + "key": "ENABLE_EMAIL_PASSWORD", + "value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), + "category": "AUTHENTICATION", + "is_encrypted": False, + }, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"), + "category": "AUTHENTICATION", + "is_encrypted": False, + }, +] + +workspace_management_config_variables = [ + { + "key": "DISABLE_WORKSPACE_CREATION", + "value": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), + "category": "WORKSPACE_MANAGEMENT", + "is_encrypted": False, + }, +] + +google_config_variables = [ + { + "key": "GOOGLE_CLIENT_ID", + "value": os.environ.get("GOOGLE_CLIENT_ID"), + "category": "GOOGLE", + "is_encrypted": False, + }, + { + "key": "GOOGLE_CLIENT_SECRET", + "value": os.environ.get("GOOGLE_CLIENT_SECRET"), + "category": "GOOGLE", + "is_encrypted": True, + }, +] + +github_config_variables = [ + { + "key": "GITHUB_CLIENT_ID", + "value": os.environ.get("GITHUB_CLIENT_ID"), + "category": "GITHUB", + "is_encrypted": False, + }, + { + "key": "GITHUB_CLIENT_SECRET", + "value": os.environ.get("GITHUB_CLIENT_SECRET"), + "category": "GITHUB", + "is_encrypted": True, + }, + { + "key": "GITHUB_ORGANIZATION_ID", + "value": os.environ.get("GITHUB_ORGANIZATION_ID"), + "category": "GITHUB", + "is_encrypted": False, + }, +] + + +gitlab_config_variables = [ + { + "key": "GITLAB_HOST", + "value": os.environ.get("GITLAB_HOST"), + "category": "GITLAB", + "is_encrypted": False, + }, + { + "key": "GITLAB_CLIENT_ID", + "value": os.environ.get("GITLAB_CLIENT_ID"), + "category": "GITLAB", + "is_encrypted": False, + }, + { + "key": "GITLAB_CLIENT_SECRET", + "value": os.environ.get("GITLAB_CLIENT_SECRET"), + "category": "GITLAB", + "is_encrypted": True, + }, +] + +gitea_config_variables = [ + { + "key": "IS_GITEA_ENABLED", + "value": os.environ.get("IS_GITEA_ENABLED", "0"), + "category": "GITEA", + "is_encrypted": False, + }, + { + "key": "GITEA_HOST", + "value": os.environ.get("GITEA_HOST"), + "category": "GITEA", + "is_encrypted": False, + }, + { + "key": "GITEA_CLIENT_ID", + "value": os.environ.get("GITEA_CLIENT_ID"), + "category": "GITEA", + "is_encrypted": False, + }, + { + "key": "GITEA_CLIENT_SECRET", + "value": os.environ.get("GITEA_CLIENT_SECRET"), + "category": "GITEA", + "is_encrypted": True, + }, +] + +smtp_config_variables = [ + { + "key": "ENABLE_SMTP", + "value": os.environ.get("ENABLE_SMTP", "0"), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_HOST", + "value": os.environ.get("EMAIL_HOST", ""), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_HOST_USER", + "value": os.environ.get("EMAIL_HOST_USER", ""), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_HOST_PASSWORD", + "value": os.environ.get("EMAIL_HOST_PASSWORD", ""), + "category": "SMTP", + "is_encrypted": True, + }, + { + "key": "EMAIL_PORT", + "value": os.environ.get("EMAIL_PORT", "587"), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_FROM", + "value": os.environ.get("EMAIL_FROM", ""), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_USE_TLS", + "value": os.environ.get("EMAIL_USE_TLS", "1"), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_USE_SSL", + "value": os.environ.get("EMAIL_USE_SSL", "0"), + "category": "SMTP", + "is_encrypted": False, + }, +] + +llm_config_variables = [ + { + "key": "LLM_API_KEY", + "value": os.environ.get("LLM_API_KEY"), + "category": "AI", + "is_encrypted": True, + }, + { + "key": "LLM_PROVIDER", + "value": os.environ.get("LLM_PROVIDER", "openai"), + "category": "AI", + "is_encrypted": False, + }, + { + "key": "LLM_MODEL", + "value": os.environ.get("LLM_MODEL", "gpt-4o-mini"), + "category": "AI", + "is_encrypted": False, + }, + # Deprecated, use LLM_MODEL + { + "key": "GPT_ENGINE", + "value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), + "category": "AI", + "is_encrypted": False, + }, +] + +unsplash_config_variables = [ + { + "key": "UNSPLASH_ACCESS_KEY", + "value": os.environ.get("UNSPLASH_ACCESS_KEY", ""), + "category": "UNSPLASH", + "is_encrypted": True, + }, +] + +intercom_config_variables = [ + { + "key": "IS_INTERCOM_ENABLED", + "value": os.environ.get("IS_INTERCOM_ENABLED", "1"), + "category": "INTERCOM", + "is_encrypted": False, + }, + { + "key": "INTERCOM_APP_ID", + "value": os.environ.get("INTERCOM_APP_ID", ""), + "category": "INTERCOM", + "is_encrypted": False, + }, +] + +core_config_variables = [ + *authentication_config_variables, + *workspace_management_config_variables, + *google_config_variables, + *github_config_variables, + *gitlab_config_variables, + *gitea_config_variables, + *smtp_config_variables, + *llm_config_variables, + *unsplash_config_variables, + *intercom_config_variables, +] diff --git a/apps/api/plane/utils/instance_config_variables/extended.py b/apps/api/plane/utils/instance_config_variables/extended.py new file mode 100644 index 0000000000..24c6fefda4 --- /dev/null +++ b/apps/api/plane/utils/instance_config_variables/extended.py @@ -0,0 +1 @@ +extended_config_variables = [] diff --git a/apps/api/plane/utils/permissions/__init__.py b/apps/api/plane/utils/permissions/__init__.py new file mode 100644 index 0000000000..849f7ba3ee --- /dev/null +++ b/apps/api/plane/utils/permissions/__init__.py @@ -0,0 +1,17 @@ +from .workspace import ( + WorkSpaceBasePermission, + WorkspaceOwnerPermission, + WorkSpaceAdminPermission, + WorkspaceEntityPermission, + WorkspaceViewerPermission, + WorkspaceUserPermission, +) +from .project import ( + ProjectBasePermission, + ProjectEntityPermission, + ProjectMemberPermission, + ProjectLitePermission, + ProjectAdminPermission, +) +from .base import allow_permission, ROLE +from .page import ProjectPagePermission diff --git a/apps/api/plane/utils/permissions/base.py b/apps/api/plane/utils/permissions/base.py new file mode 100644 index 0000000000..a2b1a18ff8 --- /dev/null +++ b/apps/api/plane/utils/permissions/base.py @@ -0,0 +1,73 @@ +from plane.db.models import WorkspaceMember, ProjectMember +from functools import wraps +from rest_framework.response import Response +from rest_framework import status + +from enum import Enum + + +class ROLE(Enum): + ADMIN = 20 + MEMBER = 15 + GUEST = 5 + + +def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None): + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(instance, request, *args, **kwargs): + # Check for creator if required + if creator and model: + obj = model.objects.filter(id=kwargs["pk"], created_by=request.user).exists() + if obj: + return view_func(instance, request, *args, **kwargs) + + # Convert allowed_roles to their values if they are enum members + allowed_role_values = [role.value if isinstance(role, ROLE) else role for role in allowed_roles] + + # Check role permissions + if level == "WORKSPACE": + if WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + role__in=allowed_role_values, + is_active=True, + ).exists(): + return view_func(instance, request, *args, **kwargs) + else: + is_user_has_allowed_role = ProjectMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + project_id=kwargs["project_id"], + role__in=allowed_role_values, + is_active=True, + ).exists() + + # Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role # noqa: E501 + if is_user_has_allowed_role: + return view_func(instance, request, *args, **kwargs) + elif ( + ProjectMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + project_id=kwargs["project_id"], + is_active=True, + ).exists() + and WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ): + return view_func(instance, request, *args, **kwargs) + + # Return permission denied if no conditions are met + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) + + return _wrapped_view + + return decorator diff --git a/apps/api/plane/utils/permissions/page.py b/apps/api/plane/utils/permissions/page.py new file mode 100644 index 0000000000..bea878f4c4 --- /dev/null +++ b/apps/api/plane/utils/permissions/page.py @@ -0,0 +1,121 @@ +from plane.db.models import ProjectMember, Page +from plane.app.permissions import ROLE + + +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +# Permission Mappings for workspace members +ADMIN = ROLE.ADMIN.value +MEMBER = ROLE.MEMBER.value +GUEST = ROLE.GUEST.value + + +class ProjectPagePermission(BasePermission): + """ + Custom permission to control access to pages within a workspace + based on user roles, page visibility (public/private), and feature flags. + """ + + def has_permission(self, request, view): + """ + Check basic project-level permissions before checking object-level permissions. + """ + if request.user.is_anonymous: + return False + + user_id = request.user.id + slug = view.kwargs.get("slug") + page_id = view.kwargs.get("page_id") + project_id = view.kwargs.get("project_id") + + # Hook for extended validation + extended_access, role = self._check_access_and_get_role(request, slug, project_id) + if extended_access is False: + return False + + if page_id: + page = Page.objects.get(id=page_id, workspace__slug=slug) + + # Allow access if the user is the owner of the page + if page.owned_by_id == user_id: + return True + + # Handle private page access + if page.access == Page.PRIVATE_ACCESS: + return self._has_private_page_action_access(request, slug, page, project_id) + + # Handle public page access + return self._has_public_page_action_access(request, role) + + def _check_project_member_access(self, request, slug, project_id): + """ + Check if the user is a project member. + """ + return ( + ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + project_id=project_id, + ) + .values_list("role", flat=True) + .first() + ) + + def _check_access_and_get_role(self, request, slug, project_id): + """ + Hook for extended access checking + Returns: True (allow), False (deny), None (continue with normal flow) + """ + role = self._check_project_member_access(request, slug, project_id) + if not role: + return False, None + return True, role + + def _has_private_page_action_access(self, request, slug, page, project_id): + """ + Check access to private pages. Override for feature flag logic. + """ + # Base implementation: only owner can access private pages + return False + + def _check_project_action_access(self, request, role): + method = request.method + + # Only admins can create (POST) pages + if method == "POST": + if role in [ADMIN, MEMBER]: + return True + return False + + # Safe methods (GET, HEAD, OPTIONS) allowed for all active roles + if method in SAFE_METHODS: + if role in [ADMIN, MEMBER, GUEST]: + return True + return False + + # PUT/PATCH: Admins and members can update + if method in ["PUT", "PATCH"]: + if role in [ADMIN, MEMBER]: + return True + return False + + # DELETE: Only admins can delete + if method == "DELETE": + if role in [ADMIN]: + return True + return False + + # Deny by default + return False + + def _has_public_page_action_access(self, request, role): + """ + Check if the user has permission to access a public page + and can perform operations on the page. + """ + project_member_exists = self._check_project_action_access(request, role) + if not project_member_exists: + return False + return True diff --git a/apps/api/plane/utils/permissions/project.py b/apps/api/plane/utils/permissions/project.py new file mode 100644 index 0000000000..a8c0f92a27 --- /dev/null +++ b/apps/api/plane/utils/permissions/project.py @@ -0,0 +1,139 @@ +# Third Party imports +from rest_framework.permissions import SAFE_METHODS, BasePermission + +# Module import +from plane.db.models import ProjectMember, WorkspaceMember +from plane.db.models.project import ROLE + + +class ProjectBasePermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user, is_active=True + ).exists() + + ## Only workspace owners or admins can create the projects + if request.method == "POST": + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + is_active=True, + ).exists() + + project_member_qs = ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project_id=view.project_id, + is_active=True, + ) + + ## Only project admins or workspace admin who is part of the project can access + + if project_member_qs.filter(role=ROLE.ADMIN.value).exists(): + return True + else: + return ( + project_member_qs.exists() + and WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ) + + +class ProjectMemberPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user, is_active=True + ).exists() + ## Only workspace owners or admins can create the projects + if request.method == "POST": + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + is_active=True, + ).exists() + + ## Only Project Admins can update project attributes + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + project_id=view.project_id, + is_active=True, + ).exists() + + +class ProjectEntityPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + # Handle requests based on project__identifier + if hasattr(view, "project_identifier") and view.project_identifier: + if request.method in SAFE_METHODS: + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project__identifier=view.project_identifier, + is_active=True, + ).exists() + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project_id=view.project_id, + is_active=True, + ).exists() + + ## Only project members or admins can create and edit the project attributes + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + project_id=view.project_id, + is_active=True, + ).exists() + + +class ProjectAdminPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role=ROLE.ADMIN.value, + project_id=view.project_id, + is_active=True, + ).exists() + + +class ProjectLitePermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project_id=view.project_id, + is_active=True, + ).exists() diff --git a/apps/api/plane/utils/permissions/workspace.py b/apps/api/plane/utils/permissions/workspace.py new file mode 100644 index 0000000000..8dc791c0cc --- /dev/null +++ b/apps/api/plane/utils/permissions/workspace.py @@ -0,0 +1,106 @@ +# Third Party imports +from rest_framework.permissions import BasePermission, SAFE_METHODS + +# Module imports +from plane.db.models import WorkspaceMember + + +# Permission Mappings +Admin = 20 +Member = 15 +Guest = 5 + + +# TODO: Move the below logic to python match - python v3.10 +class WorkSpaceBasePermission(BasePermission): + def has_permission(self, request, view): + # allow anyone to create a workspace + if request.user.is_anonymous: + return False + + if request.method == "POST": + return True + + ## Safe Methods + if request.method in SAFE_METHODS: + return True + + # allow only admins and owners to update the workspace settings + if request.method in ["PUT", "PATCH"]: + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Admin, Member], + is_active=True, + ).exists() + + # allow only owner to delete the workspace + if request.method == "DELETE": + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role=Admin, + is_active=True, + ).exists() + + +class WorkspaceOwnerPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user, role=Admin + ).exists() + + +class WorkSpaceAdminPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Admin, Member], + is_active=True, + ).exists() + + +class WorkspaceEntityPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user, is_active=True + ).exists() + + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Admin, Member], + is_active=True, + ).exists() + + +class WorkspaceViewerPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, workspace__slug=view.workspace_slug, is_active=True + ).exists() + + +class WorkspaceUserPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, workspace__slug=view.workspace_slug, is_active=True + ).exists() diff --git a/apps/live/package.json b/apps/live/package.json index ca065a8792..e8c1098d7b 100644 --- a/apps/live/package.json +++ b/apps/live/package.json @@ -1,6 +1,6 @@ { "name": "live", - "version": "1.0.0", + "version": "1.1.0", "license": "AGPL-3.0", "description": "A realtime collaborative server powers Plane's rich text editor", "main": "./dist/start.js", @@ -20,11 +20,11 @@ "author": "Plane Software Inc.", "dependencies": { "@dotenvx/dotenvx": "^1.49.0", - "@hocuspocus/extension-database": "3.2.5", - "@hocuspocus/extension-logger": "3.2.5", - "@hocuspocus/extension-redis": "3.2.5", - "@hocuspocus/server": "3.2.5", - "@hocuspocus/transformer": "3.2.5", + "@hocuspocus/extension-database": "2.15.2", + "@hocuspocus/extension-logger": "2.15.2", + "@hocuspocus/extension-redis": "2.15.2", + "@hocuspocus/server": "2.15.2", + "@hocuspocus/transformer": "2.15.2", "@plane/decorators": "workspace:*", "@plane/editor": "workspace:*", "@plane/logger": "workspace:*", @@ -53,7 +53,7 @@ "@plane/typescript-config": "workspace:*", "@types/compression": "1.8.1", "@types/cors": "^2.8.17", - "@types/express": "^4.17.23", + "@types/express": "4.17.23", "@types/express-ws": "^3.0.5", "@types/node": "catalog:", "@types/ws": "^8.18.1", diff --git a/apps/live/src/env.ts b/apps/live/src/env.ts index 062ab578f9..3c1a91ec9a 100644 --- a/apps/live/src/env.ts +++ b/apps/live/src/env.ts @@ -1,6 +1,5 @@ import * as dotenv from "@dotenvx/dotenvx"; import { z } from "zod"; -import { logger } from "@plane/logger"; dotenv.config(); @@ -28,7 +27,7 @@ const envSchema = z.object({ const validateEnv = () => { const result = envSchema.safeParse(process.env); if (!result.success) { - logger.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4)); + console.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4)); process.exit(1); } return result.data; diff --git a/apps/live/src/extensions/database.ts b/apps/live/src/extensions/database.ts index a4b18ad9e1..be7a3139c6 100644 --- a/apps/live/src/extensions/database.ts +++ b/apps/live/src/extensions/database.ts @@ -6,22 +6,17 @@ import { } from "@plane/editor"; // logger import { logger } from "@plane/logger"; -// lib +import { AppError } from "@/lib/errors"; +// services import { getPageService } from "@/services/page/handler"; // type import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types"; +import { ForceCloseReason, CloseCode } from "@/types/admin-commands"; +import { broadcastError } from "@/utils/broadcast-error"; +// force close utility +import { forceCloseDocumentAcrossServers } from "./force-close-handler"; -const normalizeToError = (error: unknown, fallbackMessage: string) => { - if (error instanceof Error) { - return error; - } - - const message = typeof error === "string" && error.trim().length > 0 ? error : fallbackMessage; - - return new Error(message); -}; - -const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWithContext) => { +const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => { try { const service = getPageService(context.documentType, context); // fetch details @@ -38,12 +33,22 @@ const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWith // return binary data return binaryData; } catch (error) { - logger.error("DATABASE_EXTENSION: Error in fetching document", error); - throw normalizeToError(error, `Failed to fetch document: ${pageId}`); + const appError = new AppError(error, { context: { pageId } }); + logger.error("Error in fetching document", appError); + + // Broadcast error to frontend for user document types + await broadcastError(instance, pageId, "Unable to load the page. Please try refreshing.", "fetch", context); + + throw appError; } }; -const storeDocument = async ({ context, state: pageBinaryData, documentName: pageId }: StorePayloadWithContext) => { +const storeDocument = async ({ + context, + state: pageBinaryData, + documentName: pageId, + instance, +}: StorePayloadWithContext) => { try { const service = getPageService(context.documentType, context); // convert binary data to all formats @@ -57,8 +62,46 @@ const storeDocument = async ({ context, state: pageBinaryData, documentName: pag }; await service.updateDescriptionBinary(pageId, payload); } catch (error) { - logger.error("DATABASE_EXTENSION: Error in updating document:", error); - throw normalizeToError(error, `Failed to update document: ${pageId}`); + const appError = new AppError(error, { context: { pageId } }); + logger.error("Error in updating document:", appError); + + // Check error types + const isContentTooLarge = appError.statusCode === 413; + + // Determine if we should disconnect and unload + const shouldDisconnect = isContentTooLarge; + + // Determine error message and code + let errorMessage: string; + let errorCode: "content_too_large" | "page_locked" | "page_archived" | undefined; + + if (isContentTooLarge) { + errorMessage = "Document is too large to save. Please reduce the content size."; + errorCode = "content_too_large"; + } else { + errorMessage = "Unable to save the page. Please try again."; + } + + // Broadcast error to frontend for user document types + await broadcastError(instance, pageId, errorMessage, "store", context, errorCode, shouldDisconnect); + + // If we should disconnect, close connections and unload document + if (shouldDisconnect) { + // Map error code to ForceCloseReason with proper types + const reason = + errorCode === "content_too_large" ? ForceCloseReason.DOCUMENT_TOO_LARGE : ForceCloseReason.CRITICAL_ERROR; + + const closeCode = errorCode === "content_too_large" ? CloseCode.DOCUMENT_TOO_LARGE : CloseCode.FORCE_CLOSE; + + // force close connections and unload document + await forceCloseDocumentAcrossServers(instance, pageId, reason, closeCode); + + // Don't throw after force close - document is already unloaded + // Throwing would cause hocuspocus's finally block to access the null document + return; + } + + throw appError; } }; diff --git a/apps/live/src/extensions/force-close-handler.ts b/apps/live/src/extensions/force-close-handler.ts new file mode 100644 index 0000000000..522d0909a2 --- /dev/null +++ b/apps/live/src/extensions/force-close-handler.ts @@ -0,0 +1,203 @@ +import type { Connection, Extension, Hocuspocus, onConfigurePayload } from "@hocuspocus/server"; +import { logger } from "@plane/logger"; +import { Redis } from "@/extensions/redis"; +import { + AdminCommand, + CloseCode, + ForceCloseReason, + getForceCloseMessage, + isForceCloseCommand, + type ClientForceCloseMessage, + type ForceCloseCommandData, +} from "@/types/admin-commands"; + +/** + * Extension to handle force close commands from other servers via Redis admin channel + */ +export class ForceCloseHandler implements Extension { + name = "ForceCloseHandler"; + priority = 999; + + async onConfigure({ instance }: onConfigurePayload) { + const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined; + + if (!redisExt) { + logger.warn("[FORCE_CLOSE_HANDLER] Redis extension not found"); + return; + } + + // Register handler for force_close admin command + redisExt.onAdminCommand(AdminCommand.FORCE_CLOSE, async (data) => { + // Type guard for safety + if (!isForceCloseCommand(data)) { + logger.error("[FORCE_CLOSE_HANDLER] Received invalid force close command"); + return; + } + + const { docId, reason, code } = data; + + const document = instance.documents.get(docId); + if (!document) { + // Not our document, ignore + return; + } + + const connectionCount = document.getConnectionsCount(); + logger.info(`[FORCE_CLOSE_HANDLER] Sending force close message to ${connectionCount} clients...`); + + // Step 1: Send force close message to ALL clients first + const forceCloseMessage: ClientForceCloseMessage = { + type: "force_close", + reason, + code, + message: getForceCloseMessage(reason), + timestamp: new Date().toISOString(), + }; + + let messageSent = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.sendStateless(JSON.stringify(forceCloseMessage)); + messageSent++; + } catch (error) { + logger.error("[FORCE_CLOSE_HANDLER] Failed to send message:", error); + } + }); + + logger.info(`[FORCE_CLOSE_HANDLER] Sent force close message to ${messageSent}/${connectionCount} clients`); + + // Wait a moment for messages to be delivered + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Step 2: Close connections + logger.info(`[FORCE_CLOSE_HANDLER] Closing ${connectionCount} connections...`); + + let closed = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.close({ code, reason }); + closed++; + } catch (error) { + logger.error("[FORCE_CLOSE_HANDLER] Failed to close connection:", error); + } + }); + + logger.info(`[FORCE_CLOSE_HANDLER] Closed ${closed}/${connectionCount} connections for ${docId}`); + }); + + logger.info("[FORCE_CLOSE_HANDLER] Registered with Redis extension"); + } +} + +/** + * Force close all connections to a document across all servers and unload it from memory. + * Used for critical errors or admin operations. + * + * @param instance - The Hocuspocus server instance + * @param pageId - The document ID to force close + * @param reason - The reason for force closing + * @param code - Optional WebSocket close code (defaults to FORCE_CLOSE) + * @returns Promise that resolves when document is closed and unloaded + * @throws Error if document not found in memory + */ +export const forceCloseDocumentAcrossServers = async ( + instance: Hocuspocus, + pageId: string, + reason: ForceCloseReason, + code: CloseCode = CloseCode.FORCE_CLOSE +): Promise => { + // STEP 1: VERIFY DOCUMENT EXISTS + const document = instance.documents.get(pageId); + + if (!document) { + logger.info(`[FORCE_CLOSE] Document ${pageId} already unloaded - no action needed`); + return; // Document already cleaned up, nothing to do + } + + const connectionsBefore = document.getConnectionsCount(); + logger.info(`[FORCE_CLOSE] Sending force close message to ${connectionsBefore} local clients...`); + + const forceCloseMessage: ClientForceCloseMessage = { + type: "force_close", + reason, + code, + message: getForceCloseMessage(reason), + timestamp: new Date().toISOString(), + }; + + let messageSentCount = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.sendStateless(JSON.stringify(forceCloseMessage)); + messageSentCount++; + } catch (error) { + logger.error("[FORCE_CLOSE] Failed to send message to client:", error); + } + }); + + logger.info(`[FORCE_CLOSE] Sent force close message to ${messageSentCount}/${connectionsBefore} clients`); + + // Wait a moment for messages to be delivered + await new Promise((resolve) => setTimeout(resolve, 50)); + + // STEP 3: CLOSE LOCAL CONNECTIONS + logger.info(`[FORCE_CLOSE] Closing ${connectionsBefore} local connections...`); + + let closedCount = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.close({ code, reason }); + closedCount++; + } catch (error) { + logger.error("[FORCE_CLOSE] Failed to close local connection:", error); + } + }); + + logger.info(`[FORCE_CLOSE] Closed ${closedCount}/${connectionsBefore} local connections`); + + // STEP 4: BROADCAST TO OTHER SERVERS + const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined; + + if (redisExt) { + const commandData: ForceCloseCommandData = { + command: AdminCommand.FORCE_CLOSE, + docId: pageId, + reason, + code, + originServer: instance.configuration.name || "unknown", + timestamp: new Date().toISOString(), + }; + + const receivers = await redisExt.publishAdminCommand(commandData); + logger.info(`[FORCE_CLOSE] Notified ${receivers} other server(s)`); + } else { + logger.warn("[FORCE_CLOSE] Redis extension not found, cannot notify other servers"); + } + + // STEP 5: WAIT FOR OTHER SERVERS + const waitTime = 800; + logger.info(`[FORCE_CLOSE] Waiting ${waitTime}ms for other servers to close connections...`); + await new Promise((resolve) => setTimeout(resolve, waitTime)); + + // STEP 6: UNLOAD DOCUMENT after closing all the connections + logger.info(`[FORCE_CLOSE] Unloading document from memory...`); + + try { + await instance.unloadDocument(document); + logger.info(`[FORCE_CLOSE] Document unloaded successfully ✅`); + } catch (unloadError: unknown) { + logger.error("[FORCE_CLOSE] UNLOAD FAILED:", unloadError); + logger.error(` Error: ${unloadError instanceof Error ? unloadError.message : "unknown"}`); + } + + // STEP 7: VERIFY UNLOAD + const documentAfterUnload = instance.documents.get(pageId); + + if (documentAfterUnload) { + logger.error( + `❌ [FORCE_CLOSE] Document still in memory!, Document ID: ${pageId}, Connections: ${documentAfterUnload.getConnectionsCount()}` + ); + } else { + logger.info(`✅ [FORCE_CLOSE] COMPLETE, Document: ${pageId}, Status: Successfully closed and unloaded`); + } +}; diff --git a/apps/live/src/extensions/redis.ts b/apps/live/src/extensions/redis.ts index 5edf63592d..66c728f2b6 100644 --- a/apps/live/src/extensions/redis.ts +++ b/apps/live/src/extensions/redis.ts @@ -1,30 +1,134 @@ import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis"; -import { OutgoingMessage } from "@hocuspocus/server"; -// redis +import { OutgoingMessage, type onConfigurePayload } from "@hocuspocus/server"; +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; import { redisManager } from "@/redis"; +import { AdminCommand } from "@/types/admin-commands"; +import type { AdminCommandData, AdminCommandHandler } from "@/types/admin-commands"; const getRedisClient = () => { const redisClient = redisManager.getClient(); if (!redisClient) { - throw new Error("Redis client not initialized"); + throw new AppError("Redis client not initialized"); } return redisClient; }; export class Redis extends HocuspocusRedis { + private adminHandlers = new Map(); + private readonly ADMIN_CHANNEL = "hocuspocus:admin"; + constructor() { super({ redis: getRedisClient() }); } - public broadcastToDocument(documentName: string, payload: any): Promise { + async onConfigure(payload: onConfigurePayload) { + await super.onConfigure(payload); + + // Subscribe to admin channel + await new Promise((resolve, reject) => { + this.sub.subscribe(this.ADMIN_CHANNEL, (error: Error) => { + if (error) { + logger.error(`[Redis] Failed to subscribe to admin channel:`, error); + reject(error); + } else { + logger.info(`[Redis] Subscribed to admin channel: ${this.ADMIN_CHANNEL}`); + resolve(); + } + }); + }); + + // Listen for admin messages + this.sub.on("message", this.handleAdminMessage); + logger.info(`[Redis] Attached admin message listener`); + } + + private handleAdminMessage = async (channel: string, message: string) => { + if (channel !== this.ADMIN_CHANNEL) return; + + try { + const data = JSON.parse(message) as AdminCommandData; + + // Validate command + if (!data.command || !Object.values(AdminCommand).includes(data.command as AdminCommand)) { + logger.warn(`[Redis] Invalid admin command received: ${data.command}`); + return; + } + + const handler = this.adminHandlers.get(data.command); + + if (handler) { + await handler(data); + } else { + logger.warn(`[Redis] No handler registered for admin command: ${data.command}`); + } + } catch (error) { + logger.error("[Redis] Error handling admin message:", error); + } + }; + + /** + * Register handler for an admin command + */ + public onAdminCommand( + command: AdminCommand, + handler: AdminCommandHandler + ) { + this.adminHandlers.set(command, handler as AdminCommandHandler); + logger.info(`[Redis] Registered admin command: ${command}`); + } + + /** + * Publish admin command to global channel + */ + public async publishAdminCommand(data: T): Promise { + // Validate command data + if (!data.command || !Object.values(AdminCommand).includes(data.command)) { + throw new AppError(`Invalid admin command: ${data.command}`); + } + + const message = JSON.stringify(data); + const receivers = await this.pub.publish(this.ADMIN_CHANNEL, message); + + logger.info(`[Redis] Published "${data.command}" command, received by ${receivers} server(s)`); + return receivers; + } + + async onDestroy() { + // Unsubscribe from admin channel + await new Promise((resolve) => { + this.sub.unsubscribe(this.ADMIN_CHANNEL, (error: Error) => { + if (error) { + logger.error(`[Redis] Error unsubscribing from admin channel:`, error); + } + resolve(); + }); + }); + + // Remove the message listener to prevent memory leaks + this.sub.removeListener("message", this.handleAdminMessage); + logger.info(`[Redis] Removed admin message listener`); + + await super.onDestroy(); + } + + /** + * Broadcast a message to a document across all servers via Redis. + * Uses empty identifier so ALL servers process the message. + */ + public async broadcastToDocument(documentName: string, payload: unknown): Promise { const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload); + const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload); - return this.pub.publish( - // we're accessing the private method of the hocuspocus redis extension - this["pubKey"](documentName), - // we're accessing the private method of the hocuspocus redis extension to encode the message - this["encodeMessage"](message.toUint8Array()) - ); + const emptyPrefix = Buffer.concat([Buffer.from([0])]); + const channel = this["pubKey"](documentName); + const encodedMessage = Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())]); + + const result = await this.pub.publishBuffer(channel, encodedMessage); + + logger.info(`REDIS_EXTENSION: Published to ${documentName}, ${result} subscribers`); + + return result; } } diff --git a/apps/live/src/lib/auth-middleware.ts b/apps/live/src/lib/auth-middleware.ts new file mode 100644 index 0000000000..8cdfc1b32d --- /dev/null +++ b/apps/live/src/lib/auth-middleware.ts @@ -0,0 +1,50 @@ +import type { Request, Response, NextFunction } from "express"; +import { logger } from "@plane/logger"; +import { env } from "@/env"; + +/** + * Express middleware to verify secret key authentication for protected endpoints + * + * Checks for secret key in headers: + * - x-admin-secret-key (preferred for admin endpoints) + * - live-server-secret-key (for backward compatibility) + * + * @param req - Express request object + * @param res - Express response object + * @param next - Express next function + * + * @example + * ```typescript + * import { Middleware } from "@plane/decorators"; + * import { requireSecretKey } from "@/lib/auth-middleware"; + * + * @Get("/protected") + * @Middleware(requireSecretKey) + * async protectedEndpoint(req: Request, res: Response) { + * // This will only execute if secret key is valid + * } + * ``` + */ +// TODO - Move to hmac +export const requireSecretKey = (req: Request, res: Response, next: NextFunction): void => { + const secretKey = req.headers["live-server-secret-key"]; + + if (!secretKey || secretKey !== env.LIVE_SERVER_SECRET_KEY) { + logger.warn(` + ⚠️ [AUTH] Unauthorized access attempt + Endpoint: ${req.path} + Method: ${req.method} + IP: ${req.ip} + User-Agent: ${req.headers["user-agent"]} + `); + + res.status(401).json({ + error: "Unauthorized", + status: 401, + }); + return; + } + + // Secret key is valid, proceed to the route handler + next(); +}; diff --git a/apps/live/src/lib/auth.ts b/apps/live/src/lib/auth.ts index 8b5c37d94f..a1e82314a4 100644 --- a/apps/live/src/lib/auth.ts +++ b/apps/live/src/lib/auth.ts @@ -2,6 +2,7 @@ import type { IncomingHttpHeaders } from "http"; import type { TUserDetails } from "@plane/editor"; import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; // services import { UserService } from "@/services/user.service"; // types @@ -35,8 +36,10 @@ export const onAuthenticate = async ({ userId = parsedToken.id; cookie = parsedToken.cookie; } catch (error) { - // If token parsing fails, fallback to request headers - logger.error("AUTH: Token parsing failed, using request headers:", error); + const appError = new AppError(error, { + context: { operation: "onAuthenticate" }, + }); + logger.error("Token parsing failed, using request headers", appError); } finally { // If cookie is still not found, fallback to request headers if (!cookie) { @@ -45,7 +48,9 @@ export const onAuthenticate = async ({ } if (!cookie || !userId) { - throw new Error("Credentials not provided"); + const appError = new AppError("Credentials not provided", { code: "AUTH_MISSING_CREDENTIALS" }); + logger.error("Credentials not provided", appError); + throw appError; } // set cookie in context, so it can be used throughout the ws connection @@ -67,7 +72,7 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string; const userService = new UserService(); const user = await userService.currentUser(cookie); if (user.id !== userId) { - throw new Error("Authentication unsuccessful!"); + throw new AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" }); } return { @@ -77,7 +82,10 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string; }, }; } catch (error) { - logger.error("AUTH: Token parsing failed, using request headers:", error); - throw Error("Authentication unsuccessful!"); + const appError = new AppError(error, { + context: { operation: "handleAuthentication" }, + }); + logger.error("Authentication failed", appError); + throw new AppError("Authentication unsuccessful", { code: appError.code }); } }; diff --git a/apps/live/src/lib/errors.ts b/apps/live/src/lib/errors.ts new file mode 100644 index 0000000000..480d4317bc --- /dev/null +++ b/apps/live/src/lib/errors.ts @@ -0,0 +1,73 @@ +import { AxiosError } from "axios"; + +/** + * Application error class that sanitizes and standardizes errors across the app. + * Extracts only essential information from AxiosError to prevent massive log bloat + * and sensitive data leaks (cookies, tokens, etc). + * + * Usage: + * new AppError("Simple error message") + * new AppError("Custom error", { code: "MY_CODE", statusCode: 400 }) + * new AppError(axiosError) // Auto-extracts essential info + * new AppError(anyError) // Works with any error type + */ +export class AppError extends Error { + statusCode?: number; + method?: string; + url?: string; + code?: string; + context?: Record; + + constructor(messageOrError: string | unknown, data?: Partial>) { + // Handle error objects - extract essential info + const error = messageOrError; + + // Already AppError - return immediately for performance (no need to re-process) + if (error instanceof AppError) { + return error; + } + + // Handle string message (simple case like regular Error) + if (typeof messageOrError === "string") { + super(messageOrError); + this.name = "AppError"; + if (data) { + Object.assign(this, data); + } + return; + } + + // AxiosError - extract ONLY essential info (no config, no headers, no cookies) + if (error && typeof error === "object" && "isAxiosError" in error) { + const axiosError = error as AxiosError; + const responseData = axiosError.response?.data as any; + super(responseData?.message || axiosError.message); + this.name = "AppError"; + this.statusCode = axiosError.response?.status; + this.method = axiosError.config?.method?.toUpperCase(); + this.url = axiosError.config?.url; + this.code = axiosError.code; + return; + } + + // DOMException (AbortError from cancelled requests) + if (error instanceof DOMException && error.name === "AbortError") { + super(error.message); + this.name = "AppError"; + this.code = "ABORT_ERROR"; + return; + } + + // Standard Error objects + if (error instanceof Error) { + super(error.message); + this.name = "AppError"; + this.code = error.name; + return; + } + + // Unknown error types - safe fallback + super("Unknown error occurred"); + this.name = "AppError"; + } +} diff --git a/apps/live/src/lib/stateless.ts b/apps/live/src/lib/stateless.ts index 178b87da69..2f74f0e94a 100644 --- a/apps/live/src/lib/stateless.ts +++ b/apps/live/src/lib/stateless.ts @@ -6,7 +6,7 @@ import { DocumentCollaborativeEvents, type TDocumentEventsServer } from "@plane/ * @param param0 */ export const onStateless = async ({ payload, document }: onStatelessPayload) => { - const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client; + const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer]?.client; if (response) { document.broadcastStateless(response); } diff --git a/apps/live/src/redis.ts b/apps/live/src/redis.ts index f1dacd64a5..aac0eb7126 100644 --- a/apps/live/src/redis.ts +++ b/apps/live/src/redis.ts @@ -59,13 +59,20 @@ export class RedisManager { return; } + // Configuration optimized for BOTH regular operations AND pub/sub + // HocuspocusRedis uses .duplicate() which inherits these settings this.redisClient = new Redis(redisUrl, { - lazyConnect: true, + lazyConnect: false, // Connect immediately for reliability (duplicates inherit this) keepAlive: 30000, connectTimeout: 10000, - commandTimeout: 5000, - // enableOfflineQueue: false, maxRetriesPerRequest: 3, + enableOfflineQueue: true, // Keep commands queued during reconnection + retryStrategy: (times: number) => { + // Exponential backoff with max 2 seconds + const delay = Math.min(times * 50, 2000); + logger.info(`REDIS_MANAGER: Reconnection attempt ${times}, delay: ${delay}ms`); + return delay; + }, }); // Set up event listeners @@ -94,10 +101,6 @@ export class RedisManager { this.isConnected = false; }); - // Connect to Redis - await this.redisClient.connect(); - - // Test the connection await this.redisClient.ping(); logger.info("REDIS_MANAGER: Redis connection test successful"); } catch (error) { diff --git a/apps/live/src/server.ts b/apps/live/src/server.ts index 6f8a40f254..1535844fec 100644 --- a/apps/live/src/server.ts +++ b/apps/live/src/server.ts @@ -39,7 +39,6 @@ export class Server { const manager = HocusPocusServerManager.getInstance(); this.hocuspocusServer = await manager.initialize(); logger.info("SERVER: HocusPocus setup completed"); - this.setupRoutes(this.hocuspocusServer); this.setupNotFoundHandler(); } catch (error) { diff --git a/apps/live/src/services/api.service.ts b/apps/live/src/services/api.service.ts index a5531d6b8b..8c2cb2e314 100644 --- a/apps/live/src/services/api.service.ts +++ b/apps/live/src/services/api.service.ts @@ -1,6 +1,6 @@ import axios, { AxiosInstance } from "axios"; -import { logger } from "@plane/logger"; import { env } from "@/env"; +import { AppError } from "@/lib/errors"; export abstract class APIService { protected baseURL: string; @@ -21,8 +21,7 @@ export abstract class APIService { this.axiosInstance.interceptors.response.use( (response) => response, (error) => { - logger.error("AXIOS_ERROR:", error); - return Promise.reject(error); + return Promise.reject(new AppError(error)); } ); } diff --git a/apps/live/src/services/page/core.service.ts b/apps/live/src/services/page/core.service.ts index 922c1e5d50..ca4d5d28bd 100644 --- a/apps/live/src/services/page/core.service.ts +++ b/apps/live/src/services/page/core.service.ts @@ -1,5 +1,7 @@ +import { logger } from "@plane/logger"; import { TPage } from "@plane/types"; // services +import { AppError } from "@/lib/errors"; import { APIService } from "../api.service"; export type TPageDescriptionPayload = { @@ -21,7 +23,11 @@ export abstract class PageCoreService extends APIService { }) .then((response) => response?.data) .catch((error) => { - throw error; + const appError = new AppError(error, { + context: { operation: "fetchDetails", pageId }, + }); + logger.error("Failed to fetch page details", appError); + throw appError; }); } @@ -35,7 +41,11 @@ export abstract class PageCoreService extends APIService { }) .then((response) => response?.data) .catch((error) => { - throw error; + const appError = new AppError(error, { + context: { operation: "fetchDescriptionBinary", pageId }, + }); + logger.error("Failed to fetch page description binary", appError); + throw appError; }); } @@ -50,7 +60,7 @@ export abstract class PageCoreService extends APIService { // Early abort check if (abortSignal?.aborted) { - throw new DOMException("Aborted", "AbortError"); + throw new AppError(new DOMException("Aborted", "AbortError")); } // Create an abort listener that will reject the pending promise @@ -58,7 +68,7 @@ export abstract class PageCoreService extends APIService { const abortPromise = new Promise((_, reject) => { if (abortSignal) { abortListener = () => { - reject(new DOMException("Aborted", "AbortError")); + reject(new AppError(new DOMException("Aborted", "AbortError"))); }; abortSignal.addEventListener("abort", abortListener); } @@ -66,16 +76,22 @@ export abstract class PageCoreService extends APIService { try { return await Promise.race([ - this.patch(`${this.basePath}/pages/${pageId}`, data, { + this.patch(`${this.basePath}/pages/${pageId}/`, data, { headers: this.getHeader(), signal: abortSignal, }) .then((response) => response?.data) .catch((error) => { - if (error.name === "AbortError") { - throw new DOMException("Aborted", "AbortError"); + const appError = new AppError(error, { + context: { operation: "updatePageProperties", pageId }, + }); + + if (appError.code === "ABORT_ERROR") { + throw appError; } - throw error; + + logger.error("Failed to update page properties", appError); + throw appError; }), abortPromise, ]); @@ -93,7 +109,11 @@ export abstract class PageCoreService extends APIService { }) .then((response) => response?.data) .catch((error) => { - throw error; + const appError = new AppError(error, { + context: { operation: "updateDescriptionBinary", pageId }, + }); + logger.error("Failed to update page description binary", appError); + throw appError; }); } } diff --git a/apps/live/src/services/page/handler.ts b/apps/live/src/services/page/handler.ts index fab4077d60..9b2f5adac3 100644 --- a/apps/live/src/services/page/handler.ts +++ b/apps/live/src/services/page/handler.ts @@ -1,3 +1,4 @@ +import { AppError } from "@/lib/errors"; import type { HocusPocusServerContext, TDocumentTypes } from "@/types"; // services import { ProjectPageService } from "./project-page.service"; @@ -11,5 +12,5 @@ export const getPageService = (documentType: TDocumentTypes, context: HocusPocus }); } - throw new Error(`Invalid document type ${documentType} provided.`); + throw new AppError(`Invalid document type ${documentType} provided.`); }; diff --git a/apps/live/src/services/page/project-page.service.ts b/apps/live/src/services/page/project-page.service.ts index 4a659c2436..89a1156272 100644 --- a/apps/live/src/services/page/project-page.service.ts +++ b/apps/live/src/services/page/project-page.service.ts @@ -1,3 +1,4 @@ +import { AppError } from "@/lib/errors"; import { PageService } from "./extended.service"; interface ProjectPageServiceParams { @@ -13,9 +14,9 @@ export class ProjectPageService extends PageService { constructor(params: ProjectPageServiceParams) { super(); const { workspaceSlug, projectId } = params; - if (!workspaceSlug || !projectId) throw new Error("Missing required fields."); + if (!workspaceSlug || !projectId) throw new AppError("Missing required fields."); // validate cookie - if (!params.cookie) throw new Error("Cookie is required."); + if (!params.cookie) throw new AppError("Cookie is required."); // set cookie this.setHeader("Cookie", params.cookie); // set base path diff --git a/apps/live/src/services/user.service.ts b/apps/live/src/services/user.service.ts index 8a7bcc2e4e..272d7543c0 100644 --- a/apps/live/src/services/user.service.ts +++ b/apps/live/src/services/user.service.ts @@ -1,6 +1,8 @@ // types +import { logger } from "@plane/logger"; import type { IUser } from "@plane/types"; // services +import { AppError } from "@/lib/errors"; import { APIService } from "@/services/api.service"; export class UserService extends APIService { @@ -22,7 +24,11 @@ export class UserService extends APIService { }) .then((response) => response?.data) .catch((error) => { - throw error; + const appError = new AppError(error, { + context: { operation: "currentUser" }, + }); + logger.error("Failed to fetch current user", appError); + throw appError; }); } } diff --git a/apps/live/src/start.ts b/apps/live/src/start.ts index 421a7be88f..7929b9b9bc 100644 --- a/apps/live/src/start.ts +++ b/apps/live/src/start.ts @@ -1,8 +1,9 @@ +// eslint-disable-next-line import/order import { setupSentry } from "./instrument"; setupSentry(); -// eslint-disable-next-line import/order import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; import { Server } from "./server"; let server: Server; @@ -20,28 +21,41 @@ async function startServer() { startServer(); -// Graceful shutdown on unhandled rejection -process.on("unhandledRejection", async (err: Error) => { - logger.error(`UNHANDLED REJECTION!`, err); +// Handle process signals +process.on("SIGTERM", async () => { + logger.info("Received SIGTERM signal. Initiating graceful shutdown..."); try { - // if (server) { - // await server.destroy(); - // } - } finally { - // logger.info("Exiting process..."); - // process.exit(1); + if (server) { + await server.destroy(); + } + logger.info("Server shut down gracefully"); + } catch (error) { + logger.error("Error during graceful shutdown:", error); + process.exit(1); } + process.exit(0); }); -// Graceful shutdown on uncaught exception -process.on("uncaughtException", async (err: Error) => { - logger.error(`UNCAUGHT EXCEPTION!`, err); +process.on("SIGINT", async () => { + logger.info("Received SIGINT signal. Killing node process..."); try { - // if (server) { - // await server.destroy(); - // } - } finally { - // logger.info("Exiting process..."); - // process.exit(1); + if (server) { + await server.destroy(); + } + logger.info("Server shut down gracefully"); + } catch (error) { + logger.error("Error during graceful shutdown:", error); + process.exit(1); } + process.exit(1); +}); + +process.on("unhandledRejection", (err: Error) => { + const error = new AppError(err); + logger.error(`[UNHANDLED_REJECTION]`, error); +}); + +process.on("uncaughtException", (err: Error) => { + const error = new AppError(err); + logger.error(`[UNCAUGHT_EXCEPTION]`, error); }); diff --git a/apps/live/src/types/admin-commands.ts b/apps/live/src/types/admin-commands.ts new file mode 100644 index 0000000000..bd8e5cd594 --- /dev/null +++ b/apps/live/src/types/admin-commands.ts @@ -0,0 +1,143 @@ +/** + * Type-safe admin commands for server-to-server communication + */ + +/** + * Force close error codes - reasons why a document is being force closed + */ +export enum ForceCloseReason { + CRITICAL_ERROR = "critical_error", + MEMORY_LEAK = "memory_leak", + DOCUMENT_TOO_LARGE = "document_too_large", + ADMIN_REQUEST = "admin_request", + SERVER_SHUTDOWN = "server_shutdown", + SECURITY_VIOLATION = "security_violation", + CORRUPTION_DETECTED = "corruption_detected", +} + +/** + * WebSocket close codes + * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code + */ +export enum CloseCode { + /** Normal closure; the connection successfully completed */ + NORMAL = 1000, + /** The endpoint is going away (server shutdown or browser navigating away) */ + GOING_AWAY = 1001, + /** Protocol error */ + PROTOCOL_ERROR = 1002, + /** Unsupported data */ + UNSUPPORTED_DATA = 1003, + /** Reserved (no status code was present) */ + NO_STATUS = 1005, + /** Abnormal closure */ + ABNORMAL = 1006, + /** Invalid frame payload data */ + INVALID_DATA = 1007, + /** Policy violation */ + POLICY_VIOLATION = 1008, + /** Message too big */ + MESSAGE_TOO_BIG = 1009, + /** Client expected extension not negotiated */ + MANDATORY_EXTENSION = 1010, + /** Server encountered unexpected condition */ + INTERNAL_ERROR = 1011, + /** Custom: Force close requested */ + FORCE_CLOSE = 4000, + /** Custom: Document too large */ + DOCUMENT_TOO_LARGE = 4001, + /** Custom: Memory pressure */ + MEMORY_PRESSURE = 4002, + /** Custom: Security violation */ + SECURITY_VIOLATION = 4003, +} + +/** + * Admin command types + */ +export enum AdminCommand { + FORCE_CLOSE = "force_close", + HEALTH_CHECK = "health_check", + RESTART_DOCUMENT = "restart_document", +} + +/** + * Force close command data structure + */ +export interface ForceCloseCommandData { + command: AdminCommand.FORCE_CLOSE; + docId: string; + reason: ForceCloseReason; + code: CloseCode; + originServer: string; + timestamp?: string; +} + +/** + * Health check command data structure + */ +export interface HealthCheckCommandData { + command: AdminCommand.HEALTH_CHECK; + originServer: string; + timestamp: string; +} + +/** + * Union type for all admin commands + */ +export type AdminCommandData = ForceCloseCommandData | HealthCheckCommandData; + +/** + * Client force close message structure (sent to clients via sendStateless) + */ +export interface ClientForceCloseMessage { + type: "force_close"; + reason: ForceCloseReason; + code: CloseCode; + message?: string; + timestamp?: string; +} + +/** + * Admin command handler function type + */ +export type AdminCommandHandler = (data: T) => Promise | void; + +/** + * Type guard to check if data is a ForceCloseCommandData + */ +export function isForceCloseCommand(data: AdminCommandData): data is ForceCloseCommandData { + return data.command === AdminCommand.FORCE_CLOSE; +} + +/** + * Type guard to check if data is a HealthCheckCommandData + */ +export function isHealthCheckCommand(data: AdminCommandData): data is HealthCheckCommandData { + return data.command === AdminCommand.HEALTH_CHECK; +} + +/** + * Validate force close reason + */ +export function isValidForceCloseReason(reason: string): reason is ForceCloseReason { + return Object.values(ForceCloseReason).includes(reason as ForceCloseReason); +} + +/** + * Get human-readable message for force close reason + */ +export function getForceCloseMessage(reason: ForceCloseReason): string { + const messages: Record = { + [ForceCloseReason.CRITICAL_ERROR]: "A critical error occurred. Please refresh the page.", + [ForceCloseReason.MEMORY_LEAK]: "Memory limit exceeded. Please refresh the page.", + [ForceCloseReason.DOCUMENT_TOO_LARGE]: + "Content limit reached and live sync is off. Create a new page or use nested pages to continue syncing.", + [ForceCloseReason.ADMIN_REQUEST]: "Connection closed by administrator. Please try again later.", + [ForceCloseReason.SERVER_SHUTDOWN]: "Server is shutting down. Please reconnect in a moment.", + [ForceCloseReason.SECURITY_VIOLATION]: "Security violation detected. Connection terminated.", + [ForceCloseReason.CORRUPTION_DETECTED]: "Data corruption detected. Please refresh the page.", + }; + + return messages[reason] || "Connection closed. Please refresh the page."; +} diff --git a/apps/live/src/utils/broadcast-error.ts b/apps/live/src/utils/broadcast-error.ts new file mode 100644 index 0000000000..3dfa9da41e --- /dev/null +++ b/apps/live/src/utils/broadcast-error.ts @@ -0,0 +1,38 @@ +import { type Hocuspocus } from "@hocuspocus/server"; +import { createRealtimeEvent } from "@plane/editor"; +import { logger } from "@plane/logger"; +import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types"; +import { broadcastMessageToPage } from "./broadcast-message"; + +// Helper to broadcast error to frontend +export const broadcastError = async ( + hocuspocusServerInstance: Hocuspocus, + pageId: string, + errorMessage: string, + errorType: "fetch" | "store", + context: FetchPayloadWithContext["context"] | StorePayloadWithContext["context"], + errorCode?: "content_too_large" | "page_locked" | "page_archived", + shouldDisconnect?: boolean +) => { + try { + const errorEvent = createRealtimeEvent({ + action: "error", + page_id: pageId, + parent_id: undefined, + descendants_ids: [], + data: { + error_message: errorMessage, + error_type: errorType, + error_code: errorCode, + should_disconnect: shouldDisconnect, + user_id: context.userId || "", + }, + workspace_slug: context.workspaceSlug || "", + user_id: context.userId || "", + }); + + await broadcastMessageToPage(hocuspocusServerInstance, pageId, errorEvent); + } catch (broadcastError) { + logger.error("Error broadcasting error message to frontend:", broadcastError); + } +}; diff --git a/apps/live/src/utils/broadcast-message.ts b/apps/live/src/utils/broadcast-message.ts new file mode 100644 index 0000000000..7c9a3ced6c --- /dev/null +++ b/apps/live/src/utils/broadcast-message.ts @@ -0,0 +1,34 @@ +import { Hocuspocus } from "@hocuspocus/server"; +import { BroadcastedEvent } from "@plane/editor"; +import { logger } from "@plane/logger"; +import { Redis } from "@/extensions/redis"; +import { AppError } from "@/lib/errors"; + +export const broadcastMessageToPage = async ( + hocuspocusServerInstance: Hocuspocus, + documentName: string, + eventData: BroadcastedEvent +): Promise => { + if (!hocuspocusServerInstance || !hocuspocusServerInstance.documents) { + const appError = new AppError("HocusPocus server not available or initialized", { + context: { operation: "broadcastMessageToPage", documentName }, + }); + logger.error("Error while broadcasting message:", appError); + return false; + } + + const redisExtension = hocuspocusServerInstance.configuration.extensions.find((ext) => ext instanceof Redis); + + if (!redisExtension) { + logger.error("BROADCAST_MESSAGE_TO_PAGE: Redis extension not found"); + return false; + } + + try { + await redisExtension.broadcastToDocument(documentName, eventData); + return true; + } catch (error) { + logger.error(`BROADCAST_MESSAGE_TO_PAGE: Error broadcasting to ${documentName}:`, error); + return false; + } +}; diff --git a/apps/live/src/utils/document.ts b/apps/live/src/utils/document.ts index c03a7e1609..318a506e09 100644 --- a/apps/live/src/utils/document.ts +++ b/apps/live/src/utils/document.ts @@ -1,83 +1,21 @@ -import { getSchema } from "@tiptap/core"; -import { generateHTML, generateJSON } from "@tiptap/html"; -import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; -import * as Y from "yjs"; -// plane editor -import { - getAllDocumentFormatsFromDocumentEditorBinaryData, - getAllDocumentFormatsFromRichTextEditorBinaryData, - getBinaryDataFromDocumentEditorHTMLString, - getBinaryDataFromRichTextEditorHTMLString, -} from "@plane/editor"; -import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib"; -// plane types -import { TDocumentPayload } from "@plane/types"; - -const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps]; -const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS); - -type TArgs = { - document_html: string; - variant: "rich" | "document"; -}; - -export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => { - const { document_html, variant } = args; - - if (variant === "rich") { - const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html); - const { contentBinaryEncoded, contentHTML, contentJSON } = - getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary); - return { - description: contentJSON, - description_html: contentHTML, - description_binary: contentBinaryEncoded, - }; - } - - if (variant === "document") { - const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html); - const { contentBinaryEncoded, contentHTML, contentJSON } = - getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary); - return { - description: contentJSON, - description_html: contentHTML, - description_binary: contentBinaryEncoded, - }; - } - - throw new Error(`Invalid variant provided: ${variant}`); -}; - -export const getAllDocumentFormatsFromBinaryData = ( - description: Uint8Array -): { - contentBinaryEncoded: string; - contentJSON: object; - contentHTML: string; -} => { - // encode binary description data - const base64Data = Buffer.from(description).toString("base64"); - const yDoc = new Y.Doc(); - Y.applyUpdate(yDoc, description); - // convert to JSON - const type = yDoc.getXmlFragment("default"); - const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON(); - // convert to HTML - const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS); - +export const generateTitleProsemirrorJson = (text: string) => { return { - contentBinaryEncoded: base64Data, - contentJSON, - contentHTML, + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + ...(text + ? { + content: [ + { + type: "text", + text, + }, + ], + } + : {}), + }, + ], }; }; - -export const getBinaryDataFromHTMLString = (descriptionHTML: string): Uint8Array => { - // convert HTML to JSON - const contentJSON = generateJSON(descriptionHTML ?? "

", DOCUMENT_EDITOR_EXTENSIONS); - // convert JSON to Y.Doc format - const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default"); - // convert Y.Doc to Uint8Array format - return Y.encodeStateAsUpdate(transformedData); -}; diff --git a/apps/space/.eslintrc.cjs b/apps/space/.eslintrc.cjs new file mode 100644 index 0000000000..af3135e6a5 --- /dev/null +++ b/apps/space/.eslintrc.cjs @@ -0,0 +1,25 @@ +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/consistent-type-specifier-style": ["error", "prefer-top-level"], + "@typescript-eslint/no-import-type-side-effects": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "separate-type-imports", + disallowTypeAnnotations: false, + }, + ], + }, +}; + + diff --git a/apps/space/.gitignore b/apps/space/.gitignore index bc7846c3c4..5449b293ac 100644 --- a/apps/space/.gitignore +++ b/apps/space/.gitignore @@ -12,6 +12,9 @@ /.next/ /out/ +# react-router +/.react-router/ + # production /build diff --git a/apps/space/Dockerfile.space b/apps/space/Dockerfile.space index 570511b9d3..89c02ae962 100644 --- a/apps/space/Dockerfile.space +++ b/apps/space/Dockerfile.space @@ -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;"] diff --git a/apps/space/app/[workspaceSlug]/[projectId]/page.ts b/apps/space/app/[workspaceSlug]/[projectId]/page.ts deleted file mode 100644 index 94c4152e45..0000000000 --- a/apps/space/app/[workspaceSlug]/[projectId]/page.ts +++ /dev/null @@ -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(); - } -} diff --git a/apps/space/app/[workspaceSlug]/[projectId]/page.tsx b/apps/space/app/[workspaceSlug]/[projectId]/page.tsx new file mode 100644 index 0000000000..40277ce9b7 --- /dev/null +++ b/apps/space/app/[workspaceSlug]/[projectId]/page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { redirect } from "react-router"; +// plane imports +import { SitesProjectPublishService } from "@plane/services"; +import type { TProjectPublishSettings } from "@plane/types"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import type { Route } from "./+types/page"; + +const publishService = new SitesProjectPublishService(); + +export const clientLoader = async ({ params, request }: Route.ClientLoaderArgs) => { + 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 ( +
+ +
+ ); +} diff --git a/apps/web/public/404.svg b/apps/space/app/assets/404.svg similarity index 100% rename from apps/web/public/404.svg rename to apps/space/app/assets/404.svg diff --git a/apps/admin/public/auth/background-pattern-dark.svg b/apps/space/app/assets/auth/background-pattern-dark.svg similarity index 100% rename from apps/admin/public/auth/background-pattern-dark.svg rename to apps/space/app/assets/auth/background-pattern-dark.svg diff --git a/apps/admin/public/auth/background-pattern.svg b/apps/space/app/assets/auth/background-pattern.svg similarity index 100% rename from apps/admin/public/auth/background-pattern.svg rename to apps/space/app/assets/auth/background-pattern.svg diff --git a/apps/space/public/favicon/apple-touch-icon.png b/apps/space/app/assets/favicon/apple-touch-icon.png similarity index 100% rename from apps/space/public/favicon/apple-touch-icon.png rename to apps/space/app/assets/favicon/apple-touch-icon.png diff --git a/apps/space/public/favicon/favicon-16x16.png b/apps/space/app/assets/favicon/favicon-16x16.png similarity index 100% rename from apps/space/public/favicon/favicon-16x16.png rename to apps/space/app/assets/favicon/favicon-16x16.png diff --git a/apps/space/public/favicon/favicon-32x32.png b/apps/space/app/assets/favicon/favicon-32x32.png similarity index 100% rename from apps/space/public/favicon/favicon-32x32.png rename to apps/space/app/assets/favicon/favicon-32x32.png diff --git a/apps/space/public/favicon/favicon.ico b/apps/space/app/assets/favicon/favicon.ico similarity index 100% rename from apps/space/public/favicon/favicon.ico rename to apps/space/app/assets/favicon/favicon.ico diff --git a/apps/admin/public/favicon/site.webmanifest b/apps/space/app/assets/favicon/site.webmanifest similarity index 100% rename from apps/admin/public/favicon/site.webmanifest rename to apps/space/app/assets/favicon/site.webmanifest diff --git a/apps/space/public/images/logo-spinner-dark.gif b/apps/space/app/assets/images/logo-spinner-dark.gif similarity index 100% rename from apps/space/public/images/logo-spinner-dark.gif rename to apps/space/app/assets/images/logo-spinner-dark.gif diff --git a/apps/space/public/images/logo-spinner-light.gif b/apps/space/app/assets/images/logo-spinner-light.gif similarity index 100% rename from apps/space/public/images/logo-spinner-light.gif rename to apps/space/app/assets/images/logo-spinner-light.gif diff --git a/apps/space/public/instance/instance-failure-dark.svg b/apps/space/app/assets/instance/instance-failure-dark.svg similarity index 100% rename from apps/space/public/instance/instance-failure-dark.svg rename to apps/space/app/assets/instance/instance-failure-dark.svg diff --git a/apps/space/public/instance/instance-failure.svg b/apps/space/app/assets/instance/instance-failure.svg similarity index 100% rename from apps/space/public/instance/instance-failure.svg rename to apps/space/app/assets/instance/instance-failure.svg diff --git a/apps/space/public/instance/intake-sent-dark.png b/apps/space/app/assets/instance/intake-sent-dark.png similarity index 100% rename from apps/space/public/instance/intake-sent-dark.png rename to apps/space/app/assets/instance/intake-sent-dark.png diff --git a/apps/space/public/instance/intake-sent-light.png b/apps/space/app/assets/instance/intake-sent-light.png similarity index 100% rename from apps/space/public/instance/intake-sent-light.png rename to apps/space/app/assets/instance/intake-sent-light.png diff --git a/apps/space/public/instance/plane-instance-not-ready.webp b/apps/space/app/assets/instance/plane-instance-not-ready.webp similarity index 100% rename from apps/space/public/instance/plane-instance-not-ready.webp rename to apps/space/app/assets/instance/plane-instance-not-ready.webp diff --git a/apps/admin/public/instance/plane-takeoff.png b/apps/space/app/assets/instance/plane-takeoff.png similarity index 100% rename from apps/admin/public/instance/plane-takeoff.png rename to apps/space/app/assets/instance/plane-takeoff.png diff --git a/apps/space/app/assets/logos/gitea-logo.svg b/apps/space/app/assets/logos/gitea-logo.svg new file mode 100644 index 0000000000..43291345df --- /dev/null +++ b/apps/space/app/assets/logos/gitea-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/space/public/logos/github-black.png b/apps/space/app/assets/logos/github-black.png similarity index 100% rename from apps/space/public/logos/github-black.png rename to apps/space/app/assets/logos/github-black.png diff --git a/apps/space/public/logos/github-dark.svg b/apps/space/app/assets/logos/github-dark.svg similarity index 100% rename from apps/space/public/logos/github-dark.svg rename to apps/space/app/assets/logos/github-dark.svg diff --git a/apps/space/public/logos/github-square.svg b/apps/space/app/assets/logos/github-square.svg similarity index 100% rename from apps/space/public/logos/github-square.svg rename to apps/space/app/assets/logos/github-square.svg diff --git a/apps/space/public/logos/github-white.svg b/apps/space/app/assets/logos/github-white.svg similarity index 100% rename from apps/space/public/logos/github-white.svg rename to apps/space/app/assets/logos/github-white.svg diff --git a/apps/space/public/logos/gitlab-logo.svg b/apps/space/app/assets/logos/gitlab-logo.svg similarity index 100% rename from apps/space/public/logos/gitlab-logo.svg rename to apps/space/app/assets/logos/gitlab-logo.svg diff --git a/apps/space/public/logos/google-logo.svg b/apps/space/app/assets/logos/google-logo.svg similarity index 100% rename from apps/space/public/logos/google-logo.svg rename to apps/space/app/assets/logos/google-logo.svg diff --git a/apps/space/public/plane-logo.svg b/apps/space/app/assets/plane-logo.svg similarity index 100% rename from apps/space/public/plane-logo.svg rename to apps/space/app/assets/plane-logo.svg diff --git a/apps/space/public/plane-logos/black-horizontal-with-blue-logo.png b/apps/space/app/assets/plane-logos/black-horizontal-with-blue-logo.png similarity index 100% rename from apps/space/public/plane-logos/black-horizontal-with-blue-logo.png rename to apps/space/app/assets/plane-logos/black-horizontal-with-blue-logo.png diff --git a/apps/space/public/plane-logos/blue-without-text-new.png b/apps/space/app/assets/plane-logos/blue-without-text-new.png similarity index 100% rename from apps/space/public/plane-logos/blue-without-text-new.png rename to apps/space/app/assets/plane-logos/blue-without-text-new.png diff --git a/apps/space/public/plane-logos/blue-without-text.png b/apps/space/app/assets/plane-logos/blue-without-text.png similarity index 100% rename from apps/space/public/plane-logos/blue-without-text.png rename to apps/space/app/assets/plane-logos/blue-without-text.png diff --git a/apps/space/public/plane-logos/white-horizontal-with-blue-logo.png b/apps/space/app/assets/plane-logos/white-horizontal-with-blue-logo.png similarity index 100% rename from apps/space/public/plane-logos/white-horizontal-with-blue-logo.png rename to apps/space/app/assets/plane-logos/white-horizontal-with-blue-logo.png diff --git a/apps/space/public/plane-logos/white-horizontal.svg b/apps/space/app/assets/plane-logos/white-horizontal.svg similarity index 100% rename from apps/space/public/plane-logos/white-horizontal.svg rename to apps/space/app/assets/plane-logos/white-horizontal.svg diff --git a/apps/space/public/project-not-published.svg b/apps/space/app/assets/project-not-published.svg similarity index 100% rename from apps/space/public/project-not-published.svg rename to apps/space/app/assets/project-not-published.svg diff --git a/apps/space/public/robots.txt b/apps/space/app/assets/robots.txt similarity index 100% rename from apps/space/public/robots.txt rename to apps/space/app/assets/robots.txt diff --git a/apps/space/public/something-went-wrong.svg b/apps/space/app/assets/something-went-wrong.svg similarity index 100% rename from apps/space/public/something-went-wrong.svg rename to apps/space/app/assets/something-went-wrong.svg diff --git a/apps/space/public/user-logged-in.svg b/apps/space/app/assets/user-logged-in.svg similarity index 100% rename from apps/space/public/user-logged-in.svg rename to apps/space/app/assets/user-logged-in.svg diff --git a/apps/space/app/compat/next/helper.ts b/apps/space/app/compat/next/helper.ts new file mode 100644 index 0000000000..fe1a984460 --- /dev/null +++ b/apps/space/app/compat/next/helper.ts @@ -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; + } +} diff --git a/apps/space/app/compat/next/image.tsx b/apps/space/app/compat/next/image.tsx new file mode 100644 index 0000000000..91de8b7810 --- /dev/null +++ b/apps/space/app/compat/next/image.tsx @@ -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 & { + src: string; +}; + +const Image: React.FC = ({ src, alt = "", ...rest }) => {alt}; + +export default Image; diff --git a/apps/space/app/compat/next/link.tsx b/apps/space/app/compat/next/link.tsx new file mode 100644 index 0000000000..4f42363272 --- /dev/null +++ b/apps/space/app/compat/next/link.tsx @@ -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 = ({ + href, + replace, + prefetch: _prefetch, + scroll: _scroll, + shallow: _shallow, + ...rest +}) => ; + +export default Link; diff --git a/apps/space/app/compat/next/navigation.ts b/apps/space/app/compat/next/navigation.ts new file mode 100644 index 0000000000..b5bec8d6c8 --- /dev/null +++ b/apps/space/app/compat/next/navigation.ts @@ -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(); +} diff --git a/apps/space/app/issues/[anchor]/client-layout.tsx b/apps/space/app/issues/[anchor]/client-layout.tsx index 398591c4d1..3b96abcab7 100644 --- a/apps/space/app/issues/[anchor]/client-layout.tsx +++ b/apps/space/app/issues/[anchor]/client-layout.tsx @@ -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) => {
-
{children}
+
+ +
); }); + +export default IssuesClientLayout; diff --git a/apps/space/app/issues/[anchor]/layout.tsx b/apps/space/app/issues/[anchor]/layout.tsx index 46f187ddc4..1ed4004f1e 100644 --- a/apps/space/app/issues/[anchor]/layout.tsx +++ b/apps/space/app/issues/[anchor]/layout.tsx @@ -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 {children}; +export default async function IssuesLayout(_props: Props) { + // return {children}; + return null; } diff --git a/apps/space/app/issues/[anchor]/page.tsx b/apps/space/app/issues/[anchor]/page.tsx index baff21324d..c4a4eb1bd0 100644 --- a/apps/space/app/issues/[anchor]/page.tsx +++ b/apps/space/app/issues/[anchor]/page.tsx @@ -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 diff --git a/apps/space/app/layout.tsx b/apps/space/app/layout.tsx deleted file mode 100644 index 05d54bd07a..0000000000 --- a/apps/space/app/layout.tsx +++ /dev/null @@ -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 ( - - - - - - - - - - -
- - <>{children} - - - - ); -} diff --git a/apps/space/app/not-found.tsx b/apps/space/app/not-found.tsx index 9b6050ed11..0ffe816c9d 100644 --- a/apps/space/app/not-found.tsx +++ b/apps/space/app/not-found.tsx @@ -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 = () => (
diff --git a/apps/space/app/provider.tsx b/apps/space/app/providers.tsx similarity index 77% rename from apps/space/app/provider.tsx rename to apps/space/app/providers.tsx index 4a0a483ad1..44093d26fc 100644 --- a/apps/space/app/provider.tsx +++ b/apps/space/app/providers.tsx @@ -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 = (props) => { - const { children } = props; - +export function AppProviders({ children }: { children: React.ReactNode }) { return ( + {children} @@ -26,4 +21,4 @@ export const AppProvider: FC = (props) => { ); -}; +} diff --git a/apps/space/app/root.tsx b/apps/space/app/root.tsx new file mode 100644 index 0000000000..680ce0b552 --- /dev/null +++ b/apps/space/app/root.tsx @@ -0,0 +1,70 @@ +import { Links, Meta, Outlet, Scripts } from "react-router"; +import type { LinksFunction } from "react-router"; +// 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"; +import globalStyles from "@/styles/globals.css?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` }, + { rel: "stylesheet", href: globalStyles }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + +
+ {children} + + + + ); +} + +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 ; +} + +export function HydrateFallback() { + return null; +} + +export function ErrorBoundary() { + return ; +} diff --git a/apps/space/app/routes.ts b/apps/space/app/routes.ts new file mode 100644 index 0000000000..bbfe623667 --- /dev/null +++ b/apps/space/app/routes.ts @@ -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; diff --git a/apps/space/app/types/next-image.d.ts b/apps/space/app/types/next-image.d.ts new file mode 100644 index 0000000000..c6026a2289 --- /dev/null +++ b/apps/space/app/types/next-image.d.ts @@ -0,0 +1,10 @@ +declare module "next/image" { + import type { FC, ImgHTMLAttributes } from "react"; + + type NextImageProps = ImgHTMLAttributes & { + src: string; + }; + + const Image: FC; + export default Image; +} diff --git a/apps/space/app/types/next-link.d.ts b/apps/space/app/types/next-link.d.ts new file mode 100644 index 0000000000..e39cc52055 --- /dev/null +++ b/apps/space/app/types/next-link.d.ts @@ -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; + export default Link; +} diff --git a/apps/space/app/types/next-navigation.d.ts b/apps/space/app/types/next-navigation.d.ts new file mode 100644 index 0000000000..67a80c4fae --- /dev/null +++ b/apps/space/app/types/next-navigation.d.ts @@ -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; + }; + + export function usePathname(): string; + export function useSearchParams(): URLSearchParams; + export function useParams>(): T; +} diff --git a/apps/space/app/types/react-router-virtual.d.ts b/apps/space/app/types/react-router-virtual.d.ts new file mode 100644 index 0000000000..68caf20ae8 --- /dev/null +++ b/apps/space/app/types/react-router-virtual.d.ts @@ -0,0 +1,5 @@ +declare module "virtual:react-router/server-build" { + import type { ServerBuild } from "react-router"; + const serverBuild: ServerBuild; + export default serverBuild; +} diff --git a/apps/space/app/views/[anchor]/layout.tsx b/apps/space/app/views/[anchor]/layout.tsx deleted file mode 100644 index e2a38071c0..0000000000 --- a/apps/space/app/views/[anchor]/layout.tsx +++ /dev/null @@ -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 ; - - if (!publishSettings || !viewData) { - return ( -
- -
- ); - } - - return ( -
-
- -
-
{children}
- -
- ); -}); - -export default ViewsLayout; diff --git a/apps/space/app/views/[anchor]/page.tsx b/apps/space/app/views/[anchor]/page.tsx deleted file mode 100644 index 5c877c89a3..0000000000 --- a/apps/space/app/views/[anchor]/page.tsx +++ /dev/null @@ -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 ( - <> - - - - ); -}); - -export default ViewsPage; diff --git a/apps/space/core/components/account/auth-forms/auth-banner.tsx b/apps/space/core/components/account/auth-forms/auth-banner.tsx index 30cd6e0933..3006beacfa 100644 --- a/apps/space/core/components/account/auth-forms/auth-banner.tsx +++ b/apps/space/core/components/account/auth-forms/auth-banner.tsx @@ -1,7 +1,7 @@ "use client"; -import type { FC } from "react"; -import { Info, X } from "lucide-react"; +import { Info } from "lucide-react"; +import { CloseIcon } from "@plane/propel/icons"; // helpers import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; @@ -10,7 +10,7 @@ type TAuthBanner = { handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void; }; -export const AuthBanner: FC = (props) => { +export const AuthBanner: React.FC = (props) => { const { bannerData, handleBannerData } = props; if (!bannerData) return <>; @@ -24,7 +24,7 @@ export const AuthBanner: FC = (props) => { className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-custom-primary-100/20 text-custom-primary-100/80" onClick={() => handleBannerData && handleBannerData(undefined)} > - +
); diff --git a/apps/space/core/components/account/auth-forms/auth-header.tsx b/apps/space/core/components/account/auth-forms/auth-header.tsx index 7996feedfa..8e8aa196cb 100644 --- a/apps/space/core/components/account/auth-forms/auth-header.tsx +++ b/apps/space/core/components/account/auth-forms/auth-header.tsx @@ -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 = (props) => { +export const AuthHeader: React.FC = (props) => { const { authMode } = props; const getHeaderSubHeader = (mode: EAuthModes | null): TAuthHeaderContent => { diff --git a/apps/space/core/components/account/auth-forms/auth-root.tsx b/apps/space/core/components/account/auth-forms/auth-root.tsx index 86452a3c6b..52f0cb6f1e 100644 --- a/apps/space/core/components/account/auth-forms/auth-root.tsx +++ b/apps/space/core/components/account/auth-forms/auth-root.tsx @@ -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,11 +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"; // local imports import { TermsAndConditions } from "../terms-and-conditions"; import { AuthBanner } from "./auth-banner"; @@ -34,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; @@ -92,7 +91,12 @@ export const AuthRoot: FC = observer(() => { const isMagicLoginEnabled = config?.is_magic_login_enabled || false; const isEmailPasswordEnabled = config?.is_email_password_enabled || false; const isOAuthEnabled = - (config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false; + (config && + (config?.is_google_enabled || + config?.is_github_enabled || + config?.is_gitlab_enabled || + config?.is_gitea_enabled)) || + false; // submit handler- email verification const handleEmailVerification = async (data: IEmailCheckData) => { @@ -189,6 +193,15 @@ export const AuthRoot: FC = observer(() => { }, enabled: config?.is_gitlab_enabled, }, + { + id: "gitea", + text: `${content} with Gitea`, + icon: Gitea Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/gitea/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_gitea_enabled, + }, ]; return ( diff --git a/apps/space/core/components/account/auth-forms/email.tsx b/apps/space/core/components/account/auth-forms/email.tsx index 7abaef6f9c..8b0fa712af 100644 --- a/apps/space/core/components/account/auth-forms/email.tsx +++ b/apps/space/core/components/account/auth-forms/email.tsx @@ -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; }; -export const AuthEmailForm: FC = observer((props) => { +export const AuthEmailForm: React.FC = observer((props) => { const { onSubmit, defaultEmail } = props; // states const [isSubmitting, setIsSubmitting] = useState(false); diff --git a/apps/space/core/components/account/terms-and-conditions.tsx b/apps/space/core/components/account/terms-and-conditions.tsx index 09611d9267..bd5a3ed2ea 100644 --- a/apps/space/core/components/account/terms-and-conditions.tsx +++ b/apps/space/core/components/account/terms-and-conditions.tsx @@ -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) => { +export const TermsAndConditions: React.FC = (props) => { const { isSignUp = false } = props; return ( diff --git a/apps/space/core/components/account/user-logged-in.tsx b/apps/space/core/components/account/user-logged-in.tsx index 51175c16ae..de8118f2e5 100644 --- a/apps/space/core/components/account/user-logged-in.tsx +++ b/apps/space/core/components/account/user-logged-in.tsx @@ -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 diff --git a/apps/space/core/components/common/logo-spinner.tsx b/apps/space/core/components/common/logo-spinner.tsx index 7b6a8e8ff9..e7dae9ab57 100644 --- a/apps/space/core/components/common/logo-spinner.tsx +++ b/apps/space/core/components/common/logo-spinner.tsx @@ -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(); diff --git a/apps/space/core/components/common/powered-by.tsx b/apps/space/core/components/common/powered-by.tsx index 653c150f9a..57734feb67 100644 --- a/apps/space/core/components/common/powered-by.tsx +++ b/apps/space/core/components/common/powered-by.tsx @@ -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 = (props) => { +export const PoweredBy: React.FC = (props) => { // props const { disabled = false } = props; diff --git a/apps/space/core/components/instance/instance-failure-view.tsx b/apps/space/core/components/instance/instance-failure-view.tsx index b1190285fe..a574f391d8 100644 --- a/apps/space/core/components/instance/instance-failure-view.tsx +++ b/apps/space/core/components/instance/instance-failure-view.tsx @@ -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; diff --git a/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx b/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx index cb542facef..bffc7c553a 100644 --- a/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx +++ b/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx @@ -1,9 +1,9 @@ "use client"; import { observer } from "mobx-react"; -import { X } from "lucide-react"; -// types import { useTranslation } from "@plane/i18n"; +import { CloseIcon } from "@plane/propel/icons"; +// types import type { TFilters } from "@/types/issue"; // components import { AppliedPriorityFilters } from "./priority"; @@ -55,7 +55,7 @@ export const AppliedFiltersList: React.FC = observer((props) => { className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" onClick={() => handleRemoveFilter(filterKey, null)} > - +
@@ -67,7 +67,7 @@ export const AppliedFiltersList: React.FC = observer((props) => { className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200" > {t("common.clear_all")} - +
); diff --git a/apps/space/core/components/issues/filters/applied-filters/label.tsx b/apps/space/core/components/issues/filters/applied-filters/label.tsx index 5abbd54bac..2b842c152e 100644 --- a/apps/space/core/components/issues/filters/applied-filters/label.tsx +++ b/apps/space/core/components/issues/filters/applied-filters/label.tsx @@ -1,6 +1,6 @@ "use client"; -import { X } from "lucide-react"; +import { CloseIcon } from "@plane/propel/icons"; // types import type { IIssueLabel } from "@/types/issue"; @@ -34,7 +34,7 @@ export const AppliedLabelsFilters: React.FC = (props) => { className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" onClick={() => handleRemove(labelId)} > - +
); diff --git a/apps/space/core/components/issues/filters/applied-filters/priority.tsx b/apps/space/core/components/issues/filters/applied-filters/priority.tsx index a687cb67c5..da151873f9 100644 --- a/apps/space/core/components/issues/filters/applied-filters/priority.tsx +++ b/apps/space/core/components/issues/filters/applied-filters/priority.tsx @@ -1,7 +1,6 @@ "use client"; -import { X } from "lucide-react"; -import { PriorityIcon } from "@plane/propel/icons"; +import { CloseIcon, PriorityIcon } from "@plane/propel/icons"; import type { TIssuePriorities } from "@plane/propel/icons"; type Props = { @@ -25,7 +24,7 @@ export const AppliedPriorityFilters: React.FC = (props) => { className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" onClick={() => handleRemove(priority)} > - + ))} diff --git a/apps/space/core/components/issues/filters/applied-filters/root.tsx b/apps/space/core/components/issues/filters/applied-filters/root.tsx index f67749f995..a266e8de9b 100644 --- a/apps/space/core/components/issues/filters/applied-filters/root.tsx +++ b/apps/space/core/components/issues/filters/applied-filters/root.tsx @@ -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 = observer((props) => { +export const IssueAppliedFilters: React.FC = observer((props) => { const { anchor } = props; // router const router = useRouter(); diff --git a/apps/space/core/components/issues/filters/applied-filters/state.tsx b/apps/space/core/components/issues/filters/applied-filters/state.tsx index c80c8688a5..1836c86a76 100644 --- a/apps/space/core/components/issues/filters/applied-filters/state.tsx +++ b/apps/space/core/components/issues/filters/applied-filters/state.tsx @@ -1,10 +1,9 @@ "use client"; import { observer } from "mobx-react"; -import { X } from "lucide-react"; // plane imports import { EIconSize } from "@plane/constants"; -import { StateGroupIcon } from "@plane/propel/icons"; +import { CloseIcon, StateGroupIcon } from "@plane/propel/icons"; // hooks import { useStates } from "@/hooks/store/use-state"; @@ -34,7 +33,7 @@ export const AppliedStateFilters: React.FC = observer((props) => { className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" onClick={() => handleRemove(stateId)} > - + ); diff --git a/apps/space/core/components/issues/filters/helpers/filter-header.tsx b/apps/space/core/components/issues/filters/helpers/filter-header.tsx index 52d7665160..4505efbdb0 100644 --- a/apps/space/core/components/issues/filters/helpers/filter-header.tsx +++ b/apps/space/core/components/issues/filters/helpers/filter-header.tsx @@ -1,8 +1,7 @@ "use client"; - import React from "react"; -// lucide icons -import { ChevronDown, ChevronUp } from "lucide-react"; +// icons +import { ChevronDownIcon, ChevronUpIcon } from "@plane/propel/icons"; interface IFilterHeader { title: string; @@ -18,7 +17,7 @@ export const FilterHeader = ({ title, isPreviewEnabled, handleIsPreviewEnabled } className="grid h-5 w-5 flex-shrink-0 place-items-center rounded hover:bg-custom-background-80" onClick={handleIsPreviewEnabled} > - {isPreviewEnabled ? : } + {isPreviewEnabled ? : } ); diff --git a/apps/space/core/components/issues/filters/root.tsx b/apps/space/core/components/issues/filters/root.tsx index 8899a3378b..6b21171c70 100644 --- a/apps/space/core/components/issues/filters/root.tsx +++ b/apps/space/core/components/issues/filters/root.tsx @@ -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 = observer((props) => { +export const IssueFiltersDropdown: React.FC = observer((props) => { const { anchor } = props; // router const router = useRouter(); diff --git a/apps/space/core/components/issues/filters/selection.tsx b/apps/space/core/components/issues/filters/selection.tsx index 3042a419d6..221e9df8cf 100644 --- a/apps/space/core/components/issues/filters/selection.tsx +++ b/apps/space/core/components/issues/filters/selection.tsx @@ -2,7 +2,8 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; -import { Search, X } from "lucide-react"; +import { Search } from "lucide-react"; +import { CloseIcon } from "@plane/propel/icons"; // types import type { IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue"; // local imports @@ -37,7 +38,7 @@ export const FilterSelection: React.FC = observer((props) => { /> {filtersSearchQuery !== "" && ( )} diff --git a/apps/space/core/components/issues/issue-layouts/error.tsx b/apps/space/core/components/issues/issue-layouts/error.tsx index 34789c2137..c52b7f34f9 100644 --- a/apps/space/core/components/issues/issue-layouts/error.tsx +++ b/apps/space/core/components/issues/issue-layouts/error.tsx @@ -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 = () => (
diff --git a/apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index 5f56b9c2d1..a529afc770 100644 --- a/apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -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 = observer((props) => { +export const HeaderGroupByCard: React.FC = observer((props) => { const { icon, title, count } = props; return ( diff --git a/apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx b/apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx index fd7ba5f0df..6e5654c932 100644 --- a/apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx @@ -1,7 +1,6 @@ -import type { FC } from "react"; -import React from "react"; import { observer } from "mobx-react"; -import { Circle, ChevronDown, ChevronUp } from "lucide-react"; +import { Circle } from "lucide-react"; +import { ChevronDownIcon, ChevronUpIcon } from "@plane/propel/icons"; // mobx interface IHeaderSubGroupByCard { @@ -12,7 +11,7 @@ interface IHeaderSubGroupByCard { toggleExpanded: () => void; } -export const HeaderSubGroupByCard: FC = observer((props) => { +export const HeaderSubGroupByCard: React.FC = observer((props) => { const { icon, title, count, isExpanded, toggleExpanded } = props; return (
= observer((props) onClick={() => toggleExpanded()} >
- {isExpanded ? : } + {isExpanded ? : }
diff --git a/apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx b/apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx index bf1b202f5c..392d17a04f 100644 --- a/apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx +++ b/apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx @@ -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; } -export const IssueBlocksList: FC = (props) => { +export const IssueBlocksList: React.FC = (props) => { const { issueIds = [], groupId, displayProperties } = props; return ( diff --git a/apps/space/core/components/issues/issue-layouts/properties/due-date.tsx b/apps/space/core/components/issues/issue-layouts/properties/due-date.tsx index 2f1669837e..71afb2489b 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/due-date.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/due-date.tsx @@ -1,7 +1,7 @@ "use client"; import { observer } from "mobx-react"; -import { CalendarCheck2 } from "lucide-react"; +import { DueDatePropertyIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // helpers @@ -33,7 +33,7 @@ export const IssueBlockDate = observer((props: Props) => { "border-[0.5px] border-custom-border-300": shouldShowBorder, })} > - + {formattedDate ? formattedDate : "No Date"}
diff --git a/apps/space/core/components/issues/issue-layouts/properties/labels.tsx b/apps/space/core/components/issues/issue-layouts/properties/labels.tsx index 12ed76d3a5..9e4b80fff5 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/labels.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/labels.tsx @@ -1,7 +1,7 @@ "use client"; import { observer } from "mobx-react"; -import { Tags } from "lucide-react"; +import { LabelPropertyIcon } from "@plane/propel/icons"; // plane imports import { Tooltip } from "@plane/propel/tooltip"; // hooks @@ -25,7 +25,7 @@ export const IssueBlockLabels = observer(({ labelIds, shouldShowLabel = false }:
- + {shouldShowLabel && No Labels}
diff --git a/apps/space/core/components/issues/issue-layouts/properties/member.tsx b/apps/space/core/components/issues/issue-layouts/properties/member.tsx index a5baae8a3b..6b1bce1f95 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/member.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/member.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; // icons import type { LucideIcon } from "lucide-react"; -import { Users } from "lucide-react"; +import { MembersPropertyIcon } from "@plane/propel/icons"; // plane ui import { Avatar, AvatarGroup } from "@plane/ui"; // plane utils @@ -49,7 +49,11 @@ export const ButtonAvatars: React.FC = observer((props: AvatarProps } } - return Icon ? : ; + return Icon ? ( + + ) : ( + + ); }); export const IssueBlockMembers = observer(({ memberIds, shouldShowBorder = true }: Props) => { diff --git a/apps/space/core/components/issues/issue-layouts/root.tsx b/apps/space/core/components/issues/issue-layouts/root.tsx index 3f9ee8d6af..bbe8186693 100644 --- a/apps/space/core/components/issues/issue-layouts/root.tsx +++ b/apps/space/core/components/issues/issue-layouts/root.tsx @@ -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 = observer((props) => { +export const IssuesLayoutsRoot: React.FC = observer((props) => { const { peekId, publishSettings } = props; // store hooks const { getIssueFilters } = useIssueFilter(); diff --git a/apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx b/apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx index 159b92a4d1..6b63d2bd5c 100644 --- a/apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx +++ b/apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx @@ -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( diff --git a/apps/space/core/components/issues/navbar/controls.tsx b/apps/space/core/components/issues/navbar/controls.tsx index 0616d8b5d2..7913f479b5 100644 --- a/apps/space/core/components/issues/navbar/controls.tsx +++ b/apps/space/core/components/issues/navbar/controls.tsx @@ -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 = observer((props) => { +export const NavbarControls: React.FC = observer((props) => { // props const { publishSettings } = props; // router diff --git a/apps/space/core/components/issues/navbar/layout-icon.tsx b/apps/space/core/components/issues/navbar/layout-icon.tsx index e9aed2b267..2d5a5db5a6 100644 --- a/apps/space/core/components/issues/navbar/layout-icon.tsx +++ b/apps/space/core/components/issues/navbar/layout-icon.tsx @@ -1,13 +1,22 @@ -import type { LucideProps } from "lucide-react"; -import { List, Kanban } from "lucide-react"; import type { TIssueLayout } from "@plane/constants"; +import { ListLayoutIcon, BoardLayoutIcon } from "@plane/propel/icons"; +import type { ISvgIcons } from "@plane/propel/icons"; + +export const IssueLayoutIcon = ({ + layout, + size, + ...props +}: { layout: TIssueLayout; size?: number } & Omit) => { + const iconProps = { + ...props, + ...(size && { width: size, height: size }), + }; -export const IssueLayoutIcon = ({ layout, ...props }: { layout: TIssueLayout } & LucideProps) => { switch (layout) { case "list": - return ; + return ; case "kanban": - return ; + return ; default: return null; } diff --git a/apps/space/core/components/issues/navbar/layout-selection.tsx b/apps/space/core/components/issues/navbar/layout-selection.tsx index 8c3c2d0428..d6c196f54f 100644 --- a/apps/space/core/components/issues/navbar/layout-selection.tsx +++ b/apps/space/core/components/issues/navbar/layout-selection.tsx @@ -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 = observer((props) => { +export const IssuesLayoutSelection: React.FC = observer((props) => { const { anchor } = props; // hooks const { t } = useTranslation(); diff --git a/apps/space/core/components/issues/navbar/root.tsx b/apps/space/core/components/issues/navbar/root.tsx index 20f407e9dc..d6e6bf08c8 100644 --- a/apps/space/core/components/issues/navbar/root.tsx +++ b/apps/space/core/components/issues/navbar/root.tsx @@ -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 = observer((props) => { +export const IssuesNavbarRoot: React.FC = observer((props) => { const { publishSettings } = props; // hooks const { project_details } = publishSettings; diff --git a/apps/space/core/components/issues/navbar/user-avatar.tsx b/apps/space/core/components/issues/navbar/user-avatar.tsx index b5538cf707..6b4d8cf0bd 100644 --- a/apps/space/core/components/issues/navbar/user-avatar.tsx +++ b/apps/space/core/components/issues/navbar/user-avatar.tsx @@ -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 diff --git a/apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx b/apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx index 6e97ed6eab..c33c1789bd 100644 --- a/apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -1,10 +1,11 @@ import React, { useRef, useState } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; -import { Check, MessageSquare, MoreVertical, X } from "lucide-react"; +import { Check, MessageSquare, MoreVertical } from "lucide-react"; import { Menu, Transition } from "@headlessui/react"; // plane imports import type { EditorRefApi } from "@plane/editor"; +import { CloseIcon } from "@plane/propel/icons"; import type { TIssuePublicComment } from "@plane/types"; import { getFileURL } from "@plane/utils"; // components @@ -136,7 +137,7 @@ export const CommentCard: React.FC = observer((props) => { className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500" onClick={() => setIsEditing(false)} > - +
diff --git a/apps/space/core/components/issues/peek-overview/issue-properties.tsx b/apps/space/core/components/issues/peek-overview/issue-properties.tsx index 6ea937d19b..94507922a5 100644 --- a/apps/space/core/components/issues/peek-overview/issue-properties.tsx +++ b/apps/space/core/components/issues/peek-overview/issue-properties.tsx @@ -2,10 +2,9 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { CalendarCheck2, Signal } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { DoubleCircleIcon, StateGroupIcon } from "@plane/propel/icons"; +import { StatePropertyIcon, StateGroupIcon, PriorityPropertyIcon, DueDatePropertyIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { cn, getIssuePriorityFilters } from "@plane/utils"; // components @@ -66,7 +65,7 @@ export const PeekOverviewIssueProperties: React.FC = observer(({ issueDet
- + State
@@ -77,7 +76,7 @@ export const PeekOverviewIssueProperties: React.FC = observer(({ issueDet
- + Priority
@@ -106,7 +105,7 @@ export const PeekOverviewIssueProperties: React.FC = observer(({ issueDet
- + Due date
@@ -116,7 +115,7 @@ export const PeekOverviewIssueProperties: React.FC = observer(({ issueDet "text-red-500": shouldHighlightIssueDueDate(issueDetails.target_date, state?.group), })} > - + {renderFormattedDate(issueDetails.target_date)}
) : ( diff --git a/apps/space/core/components/issues/peek-overview/layout.tsx b/apps/space/core/components/issues/peek-overview/layout.tsx index 817700bfab..a17e6d853c 100644 --- a/apps/space/core/components/issues/peek-overview/layout.tsx +++ b/apps/space/core/components/issues/peek-overview/layout.tsx @@ -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 = observer((props) => { +export const IssuePeekOverview: React.FC = observer((props) => { const { anchor, peekId, handlePeekClose } = props; const router = useRouter(); const searchParams = useSearchParams(); diff --git a/apps/space/core/components/ui/not-found.tsx b/apps/space/core/components/ui/not-found.tsx index 89fbca6673..abd46c5972 100644 --- a/apps/space/core/components/ui/not-found.tsx +++ b/apps/space/core/components/ui/not-found.tsx @@ -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 = () => (
- 404- Page not found + 404- Page not found

Oops! Something went wrong.

diff --git a/apps/space/core/lib/b-progress/AppProgressBar.tsx b/apps/space/core/lib/b-progress/AppProgressBar.tsx new file mode 100644 index 0000000000..1bd9362c01 --- /dev/null +++ b/apps/space/core/lib/b-progress/AppProgressBar.tsx @@ -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 = { + 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 ( + * <> + * + * + * + * ); + * } + * ``` + */ +export function AppProgressBar(): null { + const navigation = useNavigation(); + const timerRef = useRef | null>(null); + const startedRef = useRef(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; +} diff --git a/apps/space/core/lib/b-progress/index.tsx b/apps/space/core/lib/b-progress/index.tsx new file mode 100644 index 0000000000..7b531da2b2 --- /dev/null +++ b/apps/space/core/lib/b-progress/index.tsx @@ -0,0 +1 @@ +export * from "./AppProgressBar"; diff --git a/apps/space/core/lib/instance-provider.tsx b/apps/space/core/lib/instance-provider.tsx index 8ea9880861..2b005c2ae7 100644 --- a/apps/space/core/lib/instance-provider.tsx +++ b/apps/space/core/lib/instance-provider.tsx @@ -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(); diff --git a/apps/space/core/lib/store-provider.tsx b/apps/space/core/lib/store-provider.tsx index b017f90c49..cefb45c71e 100644 --- a/apps/space/core/lib/store-provider.tsx +++ b/apps/space/core/lib/store-provider.tsx @@ -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; }; diff --git a/apps/space/core/lib/toast-provider.tsx b/apps/space/core/lib/toast-provider.tsx index e76c7e01e5..22c2d7ecc2 100644 --- a/apps/space/core/lib/toast-provider.tsx +++ b/apps/space/core/lib/toast-provider.tsx @@ -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(); diff --git a/apps/space/helpers/authentication.helper.tsx b/apps/space/helpers/authentication.helper.tsx index 8c8f09c547..b4878b12c5 100644 --- a/apps/space/helpers/authentication.helper.tsx +++ b/apps/space/helpers/authentication.helper.tsx @@ -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]: { diff --git a/apps/space/helpers/common.helper.ts b/apps/space/helpers/common.helper.ts index cbb90199e9..787a64f546 100644 --- a/apps/space/helpers/common.helper.ts +++ b/apps/space/helpers/common.helper.ts @@ -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"; diff --git a/apps/space/helpers/string.helper.ts b/apps/space/helpers/string.helper.ts index 2cca3177a6..3eec9b9a1a 100644 --- a/apps/space/helpers/string.helper.ts +++ b/apps/space/helpers/string.helper.ts @@ -1,5 +1,3 @@ -import DOMPurify from "dompurify"; - export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2"); const fallbackCopyTextToClipboard = (text: string) => { @@ -50,13 +48,6 @@ export const checkEmailValidity = (email: string): boolean => { return isEmailValid; }; -export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[] = []) => { - // Remove HTML tags using regex - const cleanText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: allowedHTMLTags }); - // Trim the string and check if it's empty - return cleanText.trim() === ""; -}; - export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); diff --git a/apps/space/middleware.js b/apps/space/middleware.js new file mode 100644 index 0000000000..f4e89a9451 --- /dev/null +++ b/apps/space/middleware.js @@ -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", + }, + }); +} diff --git a/apps/space/next-env.d.ts b/apps/space/next-env.d.ts deleted file mode 100644 index 40c3d68096..0000000000 --- a/apps/space/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/space/next.config.js b/apps/space/next.config.js deleted file mode 100644 index a736f4f645..0000000000 --- a/apps/space/next.config.js +++ /dev/null @@ -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; diff --git a/apps/space/nginx/nginx.conf b/apps/space/nginx/nginx.conf new file mode 100644 index 0000000000..c0fe3730d1 --- /dev/null +++ b/apps/space/nginx/nginx.conf @@ -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; + } + } +} + diff --git a/apps/space/package.json b/apps/space/package.json index 7420b78085..e6f00ab94b 100644 --- a/apps/space/package.json +++ b/apps/space/package.json @@ -1,24 +1,26 @@ { "name": "space", - "version": "1.0.0", + "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", + "@headlessui/react": "^1.7.19", "@plane/constants": "workspace:*", "@plane/editor": "workspace:*", "@plane/i18n": "workspace:*", @@ -28,39 +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", - "dompurify": "^3.0.11", - "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" } } diff --git a/apps/space/postcss.config.js b/apps/space/postcss.config.cjs similarity index 100% rename from apps/space/postcss.config.js rename to apps/space/postcss.config.cjs diff --git a/apps/space/public/favicon/site.webmanifest b/apps/space/public/favicon/site.webmanifest deleted file mode 100644 index 1d41057813..0000000000 --- a/apps/space/public/favicon/site.webmanifest +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "", - "short_name": "", - "icons": [ - { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, - { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } - ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "standalone" -} diff --git a/apps/space/react-router.config.ts b/apps/space/react-router.config.ts new file mode 100644 index 0000000000..b046a7a121 --- /dev/null +++ b/apps/space/react-router.config.ts @@ -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; diff --git a/apps/space/server.mjs b/apps/space/server.mjs new file mode 100644 index 0000000000..3342bd0be9 --- /dev/null +++ b/apps/space/server.mjs @@ -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); +}); diff --git a/apps/space/server/app.ts b/apps/space/server/app.ts new file mode 100644 index 0000000000..d21b5bec5b --- /dev/null +++ b/apps/space/server/app.ts @@ -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); diff --git a/apps/space/styles/globals.css b/apps/space/styles/globals.css index 5f2e91ed23..46fca34027 100644 --- a/apps/space/styles/globals.css +++ b/apps/space/styles/globals.css @@ -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; +} diff --git a/apps/space/tailwind.config.js b/apps/space/tailwind.config.cjs similarity index 100% rename from apps/space/tailwind.config.js rename to apps/space/tailwind.config.cjs diff --git a/apps/space/tsconfig.json b/apps/space/tsconfig.json index a4f7c1dae8..fc26e2e4ab 100644 --- a/apps/space/tsconfig.json +++ b/apps/space/tsconfig.json @@ -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"] } diff --git a/apps/space/vite.config.ts b/apps/space/vite.config.ts new file mode 100644 index 0000000000..0ef1c5141d --- /dev/null +++ b/apps/space/vite.config.ts @@ -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>((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 + }; +}); diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000000..90b7c4387c --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +.react-router +.vite +.turbo +build +dist +*.log +.env* +!.env.example + diff --git a/apps/web/.eslintignore b/apps/web/.eslintignore index e29e17a088..99b52a07f6 100644 --- a/apps/web/.eslintignore +++ b/apps/web/.eslintignore @@ -1,4 +1,7 @@ .next/* +.react-router/* +.vite/* +build/* out/* public/* core/local-db/worker/wa-sqlite/src/* diff --git a/apps/space/.eslintrc.js b/apps/web/.eslintrc.cjs similarity index 90% rename from apps/space/.eslintrc.js rename to apps/web/.eslintrc.cjs index a0bc76d5d9..d6a1fc8336 100644 --- a/apps/space/.eslintrc.js +++ b/apps/web/.eslintrc.cjs @@ -1,6 +1,7 @@ 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 }], diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js deleted file mode 100644 index 1662fabf75..0000000000 --- a/apps/web/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: ["@plane/eslint-config/next.js"], -}; diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore index e841c6b328..9705027a77 100644 --- a/apps/web/.prettierignore +++ b/apps/web/.prettierignore @@ -1,5 +1,9 @@ .next +.react-router +.vite .turbo out/ dist/ -build/ \ No newline at end of file +build/ +node_modules +pnpm-lock.yaml diff --git a/apps/web/Dockerfile.web b/apps/web/Dockerfile.web index 873edfb155..04e04a3af5 100644 --- a/apps/web/Dockerfile.web +++ b/apps/web/Dockerfile.web @@ -71,50 +71,16 @@ ENV TURBO_TELEMETRY_DISABLED=1 RUN pnpm turbo run build --filter=web # ***************************************************************************** -# STAGE 3: Copy the project and start it +# STAGE 3: Serve with nginx # ***************************************************************************** -FROM base AS runner -WORKDIR /app +FROM nginx:1.27-alpine AS production -# Don't run production as root -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs -USER nextjs - - -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=installer /app/apps/web/.next/standalone ./ -COPY --from=installer /app/apps/web/.next/static ./apps/web/.next/static -COPY --from=installer /app/apps/web/public ./apps/web/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_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_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/web/nginx/nginx.conf /etc/nginx/nginx.conf +COPY --from=installer /app/apps/web/build/client /usr/share/nginx/html EXPOSE 3000 -CMD ["node", "apps/web/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;"] diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx index aaa7f6e51c..d81d2c1487 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx @@ -1,5 +1,6 @@ "use client"; -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; // plane imports import { SIDEBAR_WIDTH } from "@plane/constants"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx index 1ee1b3c3d7..e1db9309f9 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx @@ -1,16 +1,19 @@ "use client"; // components +import { Outlet } from "react-router"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; // local imports import { WorkspaceActiveCycleHeader } from "./header"; -export default function WorkspaceActiveCycleLayout({ children }: { children: React.ReactNode }) { +export default function WorkspaceActiveCycleLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx index 3b3e82c8f7..eb5d4bdcc6 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx @@ -8,7 +8,7 @@ import { useWorkspace } from "@/hooks/store/use-workspace"; // plane web components import { WorkspaceActiveCyclesRoot } from "@/plane-web/components/active-cycles"; -const WorkspaceActiveCyclesPage = observer(() => { +function WorkspaceActiveCyclesPage() { const { currentWorkspace } = useWorkspace(); // derived values const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Active Cycles` : undefined; @@ -19,6 +19,6 @@ const WorkspaceActiveCyclesPage = observer(() => { ); -}); +} -export default WorkspaceActiveCyclesPage; +export default observer(WorkspaceActiveCyclesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx index 29d4a54e6c..497e403667 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx @@ -1,14 +1,17 @@ "use client"; // components +import { Outlet } from "react-router"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { WorkspaceAnalyticsHeader } from "./header"; -export default function WorkspaceAnalyticsTabLayout({ children }: { children: React.ReactNode }) { +export default function WorkspaceAnalyticsTabLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx index f75edf89e5..a805da7839 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx @@ -6,31 +6,22 @@ import { useRouter } from "next/navigation"; // plane package imports import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { type TabItem, Tabs } from "@plane/ui"; +import { EmptyStateDetailed } from "@plane/propel/empty-state"; +import { Tabs } from "@plane/ui"; +import type { TabItem } from "@plane/ui"; // components import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions"; import { PageHead } from "@/components/core/page-title"; -import { ComicBoxButton } from "@/components/empty-state/comic-box-button"; -import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; // hooks import { captureClick } from "@/helpers/event-tracker.helper"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useProject } from "@/hooks/store/use-project"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserPermissions } from "@/hooks/store/user"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { getAnalyticsTabs } from "@/plane-web/components/analytics/tabs"; +import type { Route } from "./+types/page"; -type Props = { - params: { - tabId: string; - workspaceSlug: string; - }; -}; - -const AnalyticsPage = observer((props: Props) => { - // props - const { params } = props; +function AnalyticsPage({ params }: Route.ComponentProps) { const { tabId } = params; // hooks @@ -45,9 +36,6 @@ const AnalyticsPage = observer((props: Props) => { const { currentWorkspace } = useWorkspace(); const { allowPermissions } = useUserPermissions(); - // helper hooks - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" }); - // permissions const canPerformEmptyStateActions = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -72,7 +60,7 @@ const AnalyticsPage = observer((props: Props) => { })), [ANALYTICS_TABS, router, currentWorkspace?.slug] ); - const defaultTab = tabId || ANALYTICS_TABS[0].key; + const defaultTab = tabId; return ( <> @@ -95,28 +83,26 @@ const AnalyticsPage = observer((props: Props) => { />
) : ( - { + { toggleCreateProjectModal(true); captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_PROJECT_BUTTON }); - }} - disabled={!canPerformEmptyStateActions} - /> - } + }, + disabled: !canPerformEmptyStateActions, + }, + ]} /> )} )} ); -}); +} -export default AnalyticsPage; +export default observer(AnalyticsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx index f4cac61774..c9d8faf048 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx @@ -1,15 +1,18 @@ "use client"; // components +import { Outlet } from "react-router"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { ProjectIssueDetailsHeader } from "./header"; -export default function ProjectIssueDetailsLayout({ children }: { children: React.ReactNode }) { +export default function ProjectIssueDetailsLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx index dabb43eb7c..9462653f00 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx @@ -1,14 +1,16 @@ "use client"; -import React, { useEffect } from "react"; +import { useEffect } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import { useTheme } from "next-themes"; import useSWR from "swr"; // plane imports import { useTranslation } from "@plane/i18n"; import { EIssueServiceType } from "@plane/types"; import { Loader } from "@plane/ui"; +// assets +import emptyIssueDark from "@/app/assets/empty-state/search/issues-dark.webp?url"; +import emptyIssueLight from "@/app/assets/empty-state/search/issues-light.webp?url"; // components import { EmptyState } from "@/components/common/empty-state"; import { PageHead } from "@/components/core/page-title"; @@ -17,17 +19,16 @@ import { IssueDetailRoot } from "@/components/issues/issue-detail"; import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useProject } from "@/hooks/store/use-project"; -// assets import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports import { useWorkItemProperties } from "@/plane-web/hooks/use-issue-properties"; import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; -import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp"; -import emptyIssueLight from "@/public/empty-state/search/issues-light.webp"; +import type { Route } from "./+types/page"; -const IssueDetailsPage = observer(() => { +function IssueDetailsPage({ params }: Route.ComponentProps) { // router const router = useAppRouter(); - const { workspaceSlug, workItem } = useParams(); + const { workspaceSlug, workItem } = params; // hooks const { resolvedTheme } = useTheme(); // store hooks @@ -39,15 +40,11 @@ const IssueDetailsPage = observer(() => { const { getProjectById } = useProject(); const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme(); - const projectIdentifier = workItem?.toString().split("-")[0]; - const sequence_id = workItem?.toString().split("-")[1]; + const [projectIdentifier, sequence_id] = workItem.split("-"); // fetching issue details - const { data, isLoading, error } = useSWR( - workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null, - workspaceSlug && workItem - ? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) - : null + const { data, isLoading, error } = useSWR(`ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}`, () => + fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) ); const issueId = data?.id; const projectId = data?.project_id; @@ -113,14 +110,13 @@ const IssueDetailsPage = observer(() => {
) : ( - workspaceSlug && projectId && issueId && ( - + @@ -128,6 +124,6 @@ const IssueDetailsPage = observer(() => { )} ); -}); +} -export default IssueDetailsPage; +export default observer(IssueDetailsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx index 7629f6ed35..e2b6a06e6e 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx @@ -1,16 +1,19 @@ "use client"; // components +import { Outlet } from "react-router"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; // local imports import { WorkspaceDraftHeader } from "./header"; -export default function WorkspaceDraftLayout({ children }: { children: React.ReactNode }) { +export default function WorkspaceDraftLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx index 93c9b79ce8..b36a31c78f 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx @@ -1,19 +1,14 @@ "use client"; -import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core/page-title"; import { WorkspaceDraftIssuesRoot } from "@/components/issues/workspace-draft"; +import type { Route } from "./+types/page"; -const WorkspaceDraftPage = () => { - // router - const { workspaceSlug: routeWorkspaceSlug } = useParams(); +function WorkspaceDraftPage({ params }: Route.ComponentProps) { + const { workspaceSlug } = params; const pageTitle = "Workspace Draft"; - // derived values - const workspaceSlug = (routeWorkspaceSlug as string) || undefined; - - if (!workspaceSlug) return null; return ( <> @@ -22,6 +17,6 @@ const WorkspaceDraftPage = () => {
); -}; +} export default WorkspaceDraftPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx index a995bc99e7..b193dbe24e 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx @@ -17,7 +17,7 @@ import { SidebarProjectsListItem } from "@/components/workspace/sidebar/projects import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; -import { TProject } from "@/plane-web/types"; +import type { TProject } from "@/plane-web/types"; import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper"; export const ExtendedProjectSidebar = observer(() => { diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx index bf5fdb4e20..878f9ee3f0 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; // plane imports import { EXTENDED_SIDEBAR_WIDTH, SIDEBAR_WIDTH } from "@plane/constants"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx index 6aceeea954..108de05f03 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants"; -import { EUserWorkspaceRoles } from "@plane/types"; +import type { EUserWorkspaceRoles } from "@plane/types"; // hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useWorkspace } from "@/hooks/store/use-workspace"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx index 3b48668493..c780c8aade 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx @@ -1,22 +1,24 @@ "use client"; -import { CommandPalette } from "@/components/command-palette"; +import { observer } from "mobx-react"; +import { Outlet } from "react-router"; +import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; // plane web components import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; import { ProjectAppSidebar } from "./_sidebar"; -export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { +function WorkspaceLayout() { return ( - +
- {children} +
@@ -24,3 +26,5 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod ); } + +export default observer(WorkspaceLayout); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx index f8fd3a0f4c..d0083781d6 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx @@ -1,13 +1,16 @@ "use client"; +import { Outlet } from "react-router"; // components import { NotificationsSidebarRoot } from "@/components/workspace-notifications/sidebar"; -export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) { +export default function ProjectInboxIssuesLayout() { return (
-
{children}
+
+ +
); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx index 4521cf4363..2932f94f0c 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx @@ -1,7 +1,6 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // plane imports import { useTranslation } from "@plane/i18n"; // components @@ -9,9 +8,10 @@ import { PageHead } from "@/components/core/page-title"; import { NotificationsRoot } from "@/components/workspace-notifications"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; +import type { Route } from "./+types/page"; -const WorkspaceDashboardPage = observer(() => { - const { workspaceSlug } = useParams(); +function WorkspaceDashboardPage({ params }: Route.ComponentProps) { + const { workspaceSlug } = params; // plane hooks const { t } = useTranslation(); // hooks @@ -24,9 +24,9 @@ const WorkspaceDashboardPage = observer(() => { return ( <> - + ); -}); +} -export default WorkspaceDashboardPage; +export default observer(WorkspaceDashboardPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx index 446a965aec..d5ee26fa16 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx @@ -12,7 +12,7 @@ import { useWorkspace } from "@/hooks/store/use-workspace"; // local components import { WorkspaceDashboardHeader } from "./header"; -const WorkspaceDashboardPage = observer(() => { +function WorkspaceDashboardPage() { const { currentWorkspace } = useWorkspace(); const { t } = useTranslation(); // derived values @@ -27,6 +27,6 @@ const WorkspaceDashboardPage = observer(() => { ); -}); +} -export default WorkspaceDashboardPage; +export default observer(WorkspaceDashboardPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx index aac7ed4590..fdfaa22e14 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx @@ -1,10 +1,10 @@ "use client"; import React from "react"; -import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core/page-title"; import { ProfileIssuesPage } from "@/components/profile/profile-issues"; +import type { Route } from "./+types/page"; const ProfilePageHeader = { assigned: "Profile - Assigned", @@ -12,10 +12,14 @@ const ProfilePageHeader = { subscribed: "Profile - Subscribed", }; -const ProfileIssuesTypePage = () => { - const { profileViewId } = useParams() as { profileViewId: "assigned" | "subscribed" | "created" | undefined }; +function isValidProfileViewId(viewId: string): viewId is keyof typeof ProfilePageHeader { + return viewId in ProfilePageHeader; +} - if (!profileViewId) return null; +function ProfileIssuesTypePage({ params }: Route.ComponentProps) { + const { profileViewId } = params; + + if (!isValidProfileViewId(profileViewId)) return null; const header = ProfilePageHeader[profileViewId]; @@ -25,6 +29,6 @@ const ProfileIssuesTypePage = () => { ); -}; +} export default ProfileIssuesTypePage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx index cc9a789ec0..f079a1e77a 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx @@ -15,7 +15,7 @@ import { useUserPermissions } from "@/hooks/store/user"; const PER_PAGE = 100; -const ProfileActivityPage = observer(() => { +function ProfileActivityPage() { // states const [pageCount, setPageCount] = useState(1); const [totalPages, setTotalPages] = useState(0); @@ -69,6 +69,6 @@ const ProfileActivityPage = observer(() => {
); -}); +} -export default ProfileActivityPage; +export default observer(ProfileActivityPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx index 37eab6a221..cee770d807 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx @@ -1,15 +1,14 @@ "use client"; // ui -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { ChevronDown, PanelRight } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { PanelRight } from "lucide-react"; import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { YourWorkIcon } from "@plane/propel/icons"; -import { IUserProfileProjectSegregation } from "@plane/types"; +import { YourWorkIcon, ChevronDownIcon } from "@plane/propel/icons"; +import type { IUserProfileProjectSegregation } from "@plane/types"; import { Breadcrumbs, Header, CustomMenu } from "@plane/ui"; import { cn } from "@plane/utils"; // components @@ -29,6 +28,7 @@ export const UserProfileHeader: FC = observer((props) => { const { userProjectsData, type = undefined, showProfileIssuesFilter } = props; // router const { workspaceSlug, userId } = useParams(); + const router = useRouter(); // store hooks const { toggleProfileSidebar, profileSidebarCollapsed } = useAppTheme(); const { data: currentUser } = useUser(); @@ -75,7 +75,7 @@ export const UserProfileHeader: FC = observer((props) => { customButton={
{type} - +
} customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" @@ -83,14 +83,12 @@ export const UserProfileHeader: FC = observer((props) => { > <> {tabsList.map((tab) => ( - - - {t(tab.i18n_label)} - + router.push(`/${workspaceSlug}/profile/${userId}/${tab.route}`)} + > + {t(tab.i18n_label)} ))} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx index f893140591..50f1bdc568 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx @@ -1,7 +1,8 @@ "use client"; import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; +import { usePathname } from "next/navigation"; +import { Outlet } from "react-router"; import useSWR from "swr"; // components import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; @@ -16,20 +17,16 @@ import { useUserPermissions } from "@/hooks/store/user"; import useSize from "@/hooks/use-window-size"; // local components import { UserService } from "@/services/user.service"; +import type { Route } from "./+types/layout"; import { UserProfileHeader } from "./header"; import { ProfileIssuesMobileHeader } from "./mobile-header"; import { ProfileNavbar } from "./navbar"; const userService = new UserService(); -type Props = { - children: React.ReactNode; -}; - -const UseProfileLayout: React.FC = observer((props) => { - const { children } = props; +function UseProfileLayout({ params }: Route.ComponentProps) { // router - const { workspaceSlug, userId } = useParams(); + const { workspaceSlug, userId } = params; const pathname = usePathname(); // store hooks const { allowPermissions } = useUserPermissions(); @@ -43,11 +40,8 @@ const UseProfileLayout: React.FC = observer((props) => { const windowSize = useSize(); const isSmallerScreen = windowSize[0] >= 768; - const { data: userProjectsData } = useSWR( - workspaceSlug && userId ? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString()) : null, - workspaceSlug && userId - ? () => userService.getUserProfileProjectsSegregation(workspaceSlug.toString(), userId.toString()) - : null + const { data: userProjectsData } = useSWR(USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug, userId), () => + userService.getUserProfileProjectsSegregation(workspaceSlug, userId) ); // derived values const isAuthorizedPath = @@ -60,7 +54,7 @@ const UseProfileLayout: React.FC = observer((props) => { return ( <> {/* Passing the type prop from the current route value as we need the header as top most component. - TODO: We are depending on the route path to handle the mobile header type. If the path changes, this logic will break. */} + TODO: We are depending on the route path to handle the mobile header type. If the path changes, this logic will break. */}
= observer((props) => {
{isAuthorized || !isAuthorizedPath ? ( -
{children}
+
+ +
) : (
{t("you_do_not_have_the_permission_to_access_this_page")} @@ -93,6 +89,6 @@ const UseProfileLayout: React.FC = observer((props) => {
); -}); +} -export default UseProfileLayout; +export default observer(UseProfileLayout); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx index ce84d832a0..c6d4e34105 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx @@ -3,20 +3,20 @@ import { useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -// icons -import { ChevronDown } from "lucide-react"; // plane constants import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; // plane i18n import { useTranslation } from "@plane/i18n"; +// icons +import { ChevronDownIcon } from "@plane/propel/icons"; // types -import { - EIssuesStoreType, +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueLayouts, EIssueLayoutTypes, } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; // ui import { CustomMenu } from "@plane/ui"; // components @@ -88,7 +88,7 @@ export const ProfileIssuesMobileHeader = observer(() => { customButton={
{t("common.layout")} - +
} customButtonClassName="flex flex-center text-custom-text-200 text-sm" @@ -117,7 +117,7 @@ export const ProfileIssuesMobileHeader = observer(() => { menuButton={
{t("common.display")} - +
} > diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx index eeb6998c98..b63ce1affb 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx @@ -1,11 +1,10 @@ "use client"; -import { useParams } from "next/navigation"; import useSWR from "swr"; // plane imports import { GROUP_CHOICES } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IUserStateDistribution, TStateGroups } from "@plane/types"; +import type { IUserStateDistribution, TStateGroups } from "@plane/types"; import { ContentWrapper } from "@plane/ui"; // components import { PageHead } from "@/components/core/page-title"; @@ -18,15 +17,15 @@ import { ProfileWorkload } from "@/components/profile/overview/workload"; import { USER_PROFILE_DATA } from "@/constants/fetch-keys"; // services import { UserService } from "@/services/user.service"; +import type { Route } from "./+types/page"; const userService = new UserService(); -export default function ProfileOverviewPage() { - const { workspaceSlug, userId } = useParams(); +export default function ProfileOverviewPage({ params }: Route.ComponentProps) { + const { workspaceSlug, userId } = params; const { t } = useTranslation(); - const { data: userProfile } = useSWR( - workspaceSlug && userId ? USER_PROFILE_DATA(workspaceSlug.toString(), userId.toString()) : null, - workspaceSlug && userId ? () => userService.getUserProfileData(workspaceSlug.toString(), userId.toString()) : null + const { data: userProfile } = useSWR(USER_PROFILE_DATA(workspaceSlug, userId), () => + userService.getUserProfileData(workspaceSlug, userId) ); const stateDistribution: IUserStateDistribution[] = Object.keys(GROUP_CHOICES).map((key) => { diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx index c46c531732..7732b85044 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx @@ -1,15 +1,18 @@ "use client"; +import { Outlet } from "react-router"; // components import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { ProjectArchivesHeader } from "../header"; -export default function ProjectArchiveCyclesLayout({ children }: { children: React.ReactNode }) { +export default function ProjectArchiveCyclesLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx index 9894b9ca69..d99c818fae 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx @@ -1,21 +1,21 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core/page-title"; import { ArchivedCycleLayoutRoot } from "@/components/cycles/archived-cycles"; import { ArchivedCyclesHeader } from "@/components/cycles/archived-cycles/header"; // hooks import { useProject } from "@/hooks/store/use-project"; +import type { Route } from "./+types/page"; -const ProjectArchivedCyclesPage = observer(() => { +function ProjectArchivedCyclesPage({ params }: Route.ComponentProps) { // router - const { projectId } = useParams(); + const { projectId } = params; // store hooks const { getProjectById } = useProject(); // derived values - const project = projectId ? getProjectById(projectId.toString()) : undefined; + const project = getProjectById(projectId); const pageTitle = project?.name && `${project?.name} - Archived cycles`; return ( @@ -27,6 +27,6 @@ const ProjectArchivedCyclesPage = observer(() => {
); -}); +} -export default ProjectArchivedCyclesPage; +export default observer(ProjectArchivedCyclesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx index 1be30ae759..fcb28bd3a5 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { ArchiveIcon, CycleIcon, ModuleIcon, WorkItemsIcon } from "@plane/propel/icons"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx index 550bfa7b81..005c66c86b 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx @@ -1,9 +1,12 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import useSWR from "swr"; // ui +import { Banner } from "@plane/propel/banner"; +import { Button } from "@plane/propel/button"; +import { ArchiveIcon } from "@plane/propel/icons"; import { Loader } from "@plane/ui"; // components import { PageHead } from "@/components/core/page-title"; @@ -12,10 +15,12 @@ import { IssueDetailRoot } from "@/components/issues/issue-detail"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useProject } from "@/hooks/store/use-project"; +import type { Route } from "./+types/page"; -const ArchivedIssueDetailsPage = observer(() => { +function ArchivedIssueDetailsPage({ params }: Route.ComponentProps) { // router - const { workspaceSlug, projectId, archivedIssueId } = useParams(); + const { workspaceSlug, projectId, archivedIssueId } = params; + const router = useRouter(); // states // hooks const { @@ -25,17 +30,12 @@ const ArchivedIssueDetailsPage = observer(() => { const { getProjectById } = useProject(); - const { isLoading } = useSWR( - workspaceSlug && projectId && archivedIssueId - ? `ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}` - : null, - workspaceSlug && projectId && archivedIssueId - ? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString()) - : null + const { isLoading } = useSWR(`ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}`, () => + fetchIssue(workspaceSlug, projectId, archivedIssueId) ); // derived values - const issue = archivedIssueId ? getIssueById(archivedIssueId.toString()) : undefined; + const issue = getIssueById(archivedIssueId); const project = issue ? getProjectById(issue?.project_id ?? "") : undefined; const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; @@ -62,21 +62,36 @@ const ArchivedIssueDetailsPage = observer(() => {
) : ( -
-
- {workspaceSlug && projectId && archivedIssueId && ( + <> + } + action={ + + } + className="border-b border-custom-border-200" + /> +
+
- )} +
-
+ )} ); -}); +} -export default ArchivedIssueDetailsPage; +export default observer(ArchivedIssueDetailsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx index 74eb8949da..77f0c854ab 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx @@ -1,15 +1,18 @@ "use client"; +import { Outlet } from "react-router"; // components import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { ProjectArchivedIssueDetailsHeader } from "./header"; -export default function ProjectArchivedIssueDetailLayout({ children }: { children: React.ReactNode }) { +export default function ProjectArchivedIssueDetailLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx index 321ab8a62e..61105a0d4c 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx @@ -1,15 +1,18 @@ "use client"; +import { Outlet } from "react-router"; // components import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { ProjectArchivesHeader } from "../../header"; -export default function ProjectArchiveIssuesLayout({ children }: { children: React.ReactNode }) { +export default function ProjectArchiveIssuesLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx index ceb24bf3c6..dfb32d9bc2 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx @@ -1,21 +1,21 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core/page-title"; import { ArchivedIssuesHeader } from "@/components/issues/archived-issues-header"; import { ArchivedIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/archived-issue-layout-root"; // hooks import { useProject } from "@/hooks/store/use-project"; +import type { Route } from "./+types/page"; -const ProjectArchivedIssuesPage = observer(() => { +function ProjectArchivedIssuesPage({ params }: Route.ComponentProps) { // router - const { projectId } = useParams(); + const { projectId } = params; // store hooks const { getProjectById } = useProject(); // derived values - const project = projectId ? getProjectById(projectId.toString()) : undefined; + const project = getProjectById(projectId); const pageTitle = project?.name && `${project?.name} - Archived work items`; return ( @@ -27,6 +27,6 @@ const ProjectArchivedIssuesPage = observer(() => {
); -}); +} -export default ProjectArchivedIssuesPage; +export default observer(ProjectArchivedIssuesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx index ee72018acd..e7ab5f7475 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx @@ -1,15 +1,18 @@ "use client"; +import { Outlet } from "react-router"; // components import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { ProjectArchivesHeader } from "../header"; -export default function ProjectArchiveModulesLayout({ children }: { children: React.ReactNode }) { +export default function ProjectArchiveModulesLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx index 1edb13e23c..3187e73eff 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx @@ -1,20 +1,20 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core/page-title"; import { ArchivedModuleLayoutRoot, ArchivedModulesHeader } from "@/components/modules"; // hooks import { useProject } from "@/hooks/store/use-project"; +import type { Route } from "./+types/page"; -const ProjectArchivedModulesPage = observer(() => { +function ProjectArchivedModulesPage({ params }: Route.ComponentProps) { // router - const { projectId } = useParams(); + const { projectId } = params; // store hooks const { getProjectById } = useProject(); // derived values - const project = projectId ? getProjectById(projectId.toString()) : undefined; + const project = getProjectById(projectId); const pageTitle = project?.name && `${project?.name} - Archived modules`; return ( @@ -26,6 +26,6 @@ const ProjectArchivedModulesPage = observer(() => {
); -}); +} -export default ProjectArchivedModulesPage; +export default observer(ProjectArchivedModulesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx index 9f6302140e..b5ac74dad4 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx @@ -1,9 +1,10 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // plane imports import { cn } from "@plane/utils"; +// assets +import emptyCycle from "@/app/assets/empty-state/cycle.svg?url"; // components import { EmptyState } from "@/components/common/empty-state"; import { PageHead } from "@/components/core/page-title"; @@ -15,13 +16,12 @@ import { useCycle } from "@/hooks/store/use-cycle"; import { useProject } from "@/hooks/store/use-project"; import { useAppRouter } from "@/hooks/use-app-router"; import useLocalStorage from "@/hooks/use-local-storage"; -// assets -import emptyCycle from "@/public/empty-state/cycle.svg"; +import type { Route } from "./+types/page"; -const CycleDetailPage = observer(() => { +function CycleDetailPage({ params }: Route.ComponentProps) { // router const router = useAppRouter(); - const { workspaceSlug, projectId, cycleId } = useParams(); + const { workspaceSlug, projectId, cycleId } = params; // store hooks const { getCycleById, loader } = useCycle(); const { getProjectById } = useProject(); @@ -30,14 +30,14 @@ const CycleDetailPage = observer(() => { const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false); useCyclesDetails({ - workspaceSlug: workspaceSlug?.toString(), - projectId: projectId.toString(), - cycleId: cycleId.toString(), + workspaceSlug, + projectId, + cycleId, }); // derived values const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false; - const cycle = cycleId ? getCycleById(cycleId.toString()) : undefined; - const project = projectId ? getProjectById(projectId.toString()) : undefined; + const cycle = getCycleById(cycleId); + const project = getProjectById(projectId); const pageTitle = project?.name && cycle?.name ? `${project?.name} - ${cycle?.name}` : undefined; /** @@ -46,7 +46,6 @@ const CycleDetailPage = observer(() => { const toggleSidebar = () => setValue(!isSidebarCollapsed); // const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; - return ( <> @@ -66,7 +65,7 @@ const CycleDetailPage = observer(() => {
- {cycleId && !isSidebarCollapsed && ( + {!isSidebarCollapsed && (
{ >
)} @@ -89,6 +88,6 @@ const CycleDetailPage = observer(() => { )} ); -}); +} -export default CycleDetailPage; +export default observer(CycleDetailPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx index 833e1f632c..885d6bc68c 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -19,13 +19,8 @@ import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { CycleIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { - EIssuesStoreType, - ICustomSearchSelectOption, - IIssueDisplayFilterOptions, - IIssueDisplayProperties, - EIssueLayoutTypes, -} from "@plane/types"; +import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; import { Breadcrumbs, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui"; import { cn } from "@plane/utils"; // components diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx index 40872f0b47..52b4d42e40 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx @@ -1,16 +1,19 @@ "use client"; +import { Outlet } from "react-router"; // components import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { CycleIssuesHeader } from "./header"; import { CycleIssuesMobileHeader } from "./mobile-header"; -export default function ProjectCycleIssuesLayout({ children }: { children: React.ReactNode }) { +export default function ProjectCycleIssuesLayout() { return ( <> } mobileHeader={} /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx index cd4d788975..75c0a9114d 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx @@ -1,13 +1,14 @@ "use client"; import { useCallback, useState } from "react"; +import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -// icons -import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; // plane imports import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types"; +import { CalendarLayoutIcon, BoardLayoutIcon, ListLayoutIcon, ChevronDownIcon } from "@plane/propel/icons"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; import { CustomMenu } from "@plane/ui"; // components import { WorkItemsModal } from "@/components/analytics/work-items/modal"; @@ -19,12 +20,12 @@ import { useIssues } from "@/hooks/store/use-issues"; import { useProject } from "@/hooks/store/use-project"; const SUPPORTED_LAYOUTS = [ - { key: "list", titleTranslationKey: "issue.layouts.list", icon: List }, - { key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban }, - { key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar }, + { key: "list", titleTranslationKey: "issue.layouts.list", icon: ListLayoutIcon }, + { key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: BoardLayoutIcon }, + { key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: CalendarLayoutIcon }, ]; -export const CycleIssuesMobileHeader = () => { +export const CycleIssuesMobileHeader = observer(() => { // router const { workspaceSlug, projectId, cycleId } = useParams(); // states @@ -122,7 +123,7 @@ export const CycleIssuesMobileHeader = () => { menuButton={ {t("common.display")} - + } > @@ -150,4 +151,4 @@ export const CycleIssuesMobileHeader = () => {
); -}; +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx index 5cfa9e8d69..c2ac8e4551 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // ui diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx index a3caddf305..002ab55376 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx @@ -1,16 +1,19 @@ "use client"; +import { Outlet } from "react-router"; // components import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { CyclesListHeader } from "./header"; import { CyclesListMobileHeader } from "./mobile-header"; -export default function ProjectCyclesListLayout({ children }: { children: React.ReactNode }) { +export default function ProjectCyclesListLayout() { return ( <> } mobileHeader={} /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx index 759d88564c..4c8e4e9c49 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx @@ -1,10 +1,12 @@ "use client"; +import type React from "react"; import { observer } from "mobx-react"; // ui -import { GanttChartSquare, LayoutGrid, List, type LucideIcon } from "lucide-react"; +import type { ISvgIcons } from "@plane/propel/icons"; +import { TimelineLayoutIcon, GridLayoutIcon, ListLayoutIcon } from "@plane/propel/icons"; // plane package imports -import { TCycleLayoutOptions } from "@plane/types"; +import type { TCycleLayoutOptions } from "@plane/types"; import { CustomMenu } from "@plane/ui"; // hooks import { useCycleFilter } from "@/hooks/store/use-cycle-filter"; @@ -12,22 +14,22 @@ import { useProject } from "@/hooks/store/use-project"; const CYCLE_VIEW_LAYOUTS: { key: TCycleLayoutOptions; - icon: LucideIcon; + icon: React.FC; title: string; }[] = [ { key: "list", - icon: List, + icon: ListLayoutIcon, title: "List layout", }, { key: "board", - icon: LayoutGrid, + icon: GridLayoutIcon, title: "Gallery layout", }, { key: "gantt", - icon: GanttChartSquare, + icon: TimelineLayoutIcon, title: "Timeline layout", }, ]; @@ -44,7 +46,7 @@ export const CyclesListMobileHeader = observer(() => { // placement="bottom-start" customButton={ - + Layout } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx index 4783a28236..60285485f9 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx @@ -2,19 +2,24 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // plane imports +import { useTheme } from "next-themes"; import { EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EUserProjectRoles, TCycleFilters } from "@plane/types"; +import { EmptyStateDetailed } from "@plane/propel/empty-state"; +import type { TCycleFilters } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; // components import { Header, EHeaderVariant } from "@plane/ui"; import { calculateTotalFilters } from "@plane/utils"; +// assets +import darkEmptyState from "@/app/assets/empty-state/disabled-feature/cycles-dark.webp?url"; +import lightEmptyState from "@/app/assets/empty-state/disabled-feature/cycles-light.webp?url"; +// components import { PageHead } from "@/components/core/page-title"; import { CycleAppliedFiltersList } from "@/components/cycles/applied-filters"; import { CyclesView } from "@/components/cycles/cycles-view"; import { CycleCreateUpdateModal } from "@/components/cycles/modal"; -import { ComicBoxButton } from "@/components/empty-state/comic-box-button"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader"; // hooks @@ -23,9 +28,9 @@ import { useCycleFilter } from "@/hooks/store/use-cycle-filter"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import type { Route } from "./+types/page"; -const ProjectCyclesPage = observer(() => { +function ProjectCyclesPage({ params }: Route.ComponentProps) { // states const [createModal, setCreateModal] = useState(false); // store hooks @@ -33,35 +38,34 @@ const ProjectCyclesPage = observer(() => { const { getProjectById, currentProjectDetails } = useProject(); // router const router = useAppRouter(); - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug, projectId } = params; + // theme hook + const { resolvedTheme } = useTheme(); // plane hooks const { t } = useTranslation(); // cycle filters hook const { clearAllFilters, currentProjectFilters, updateFilters } = useCycleFilter(); const { allowPermissions } = useUserPermissions(); // derived values + const resolvedEmptyState = resolvedTheme === "light" ? lightEmptyState : darkEmptyState; const totalCycles = currentProjectCycleIds?.length ?? 0; - const project = projectId ? getProjectById(projectId?.toString()) : undefined; + const project = getProjectById(projectId); const pageTitle = project?.name ? `${project?.name} - ${t("common.cycles", { count: 2 })}` : undefined; const hasAdminLevelPermission = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); const hasMemberLevelPermission = allowPermissions( [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER], EUserPermissionsLevel.PROJECT ); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/cycles" }); const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => { - if (!projectId) return; let newValues = currentProjectFilters?.[key] ?? []; if (!value) newValues = []; else newValues = newValues.filter((val) => val !== value); - updateFilters(projectId.toString(), { [key]: newValues }); + updateFilters(projectId, { [key]: newValues }); }; - if (!workspaceSlug || !projectId) return <>; - // No access to cycle if (currentProjectDetails?.cycle_view === false) return ( @@ -69,7 +73,7 @@ const ProjectCyclesPage = observer(() => { { @@ -88,29 +92,26 @@ const ProjectCyclesPage = observer(() => {
setCreateModal(false)} /> {totalCycles === 0 ? (
- { - setCreateModal(true); - }} - disabled={!hasMemberLevelPermission} - /> - } + setCreateModal(true), + variant: "primary", + disabled: !hasMemberLevelPermission, + "data-ph-element": CYCLE_TRACKER_ELEMENTS.EMPTY_STATE_ADD_BUTTON, + }, + ]} />
) : ( @@ -119,18 +120,18 @@ const ProjectCyclesPage = observer(() => {
clearAllFilters(projectId.toString())} + handleClearAllFilters={() => clearAllFilters(projectId)} handleRemoveFilter={handleRemoveFilter} />
)} - + )}
); -}); +} -export default ProjectCyclesPage; +export default observer(ProjectCyclesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx index 72573c4490..24e96477e9 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx @@ -1,15 +1,18 @@ "use client"; +import { Outlet } from "react-router"; // components import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { ProjectInboxHeader } from "@/plane-web/components/projects/settings/intake/header"; -export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) { +export default function ProjectInboxIssuesLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx index 1190cdae77..e8cedf8e18 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx @@ -1,10 +1,14 @@ "use client"; import { observer } from "mobx-react"; -import { useParams, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; // plane imports import { EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { EUserProjectRoles, EInboxIssueCurrentTab } from "@plane/types"; +// assets +import darkIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-dark.webp?url"; +import lightIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-light.webp?url"; // components import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; @@ -13,15 +17,17 @@ import { InboxIssueRoot } from "@/components/inbox"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import type { Route } from "./+types/page"; -const ProjectInboxPage = observer(() => { +function ProjectInboxPage({ params }: Route.ComponentProps) { /// router const router = useAppRouter(); - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug, projectId } = params; const searchParams = useSearchParams(); const navigationTab = searchParams.get("currentTab"); const inboxIssueId = searchParams.get("inboxIssueId"); + // theme hook + const { resolvedTheme } = useTheme(); // plane hooks const { t } = useTranslation(); // hooks @@ -29,7 +35,7 @@ const ProjectInboxPage = observer(() => { const { allowPermissions } = useUserPermissions(); // derived values const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/intake" }); + const resolvedPath = resolvedTheme === "light" ? lightIntakeAsset : darkIntakeAsset; // No access to inbox if (currentProjectDetails?.inbox_view === false) @@ -65,22 +71,20 @@ const ProjectInboxPage = observer(() => { : EInboxIssueCurrentTab.CLOSED : undefined; - if (!workspaceSlug || !projectId) return <>; - return (
); -}); +} -export default ProjectInboxPage; +export default observer(ProjectInboxPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx index e63f7282ab..726686e78c 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx @@ -1,64 +1,68 @@ "use client"; -import { useEffect } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import { useTheme } from "next-themes"; -import useSWR from "swr"; +import { redirect } from "react-router"; import { useTranslation } from "@plane/i18n"; +// assets +import emptyIssueDark from "@/app/assets/empty-state/search/issues-dark.webp?url"; +import emptyIssueLight from "@/app/assets/empty-state/search/issues-light.webp?url"; // components import { EmptyState } from "@/components/common/empty-state"; import { LogoSpinner } from "@/components/common/logo-spinner"; // hooks import { useAppRouter } from "@/hooks/use-app-router"; -// assets -import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp"; -import emptyIssueLight from "@/public/empty-state/search/issues-light.webp"; // services import { IssueService } from "@/services/issue/issue.service"; +// types +import type { Route } from "./+types/page"; const issueService = new IssueService(); -const IssueDetailsPage = observer(() => { +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + const { workspaceSlug, projectId, issueId } = params; + + try { + const data = await issueService.getIssueMetaFromURL(workspaceSlug, projectId, issueId); + + if (data) { + throw redirect(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`); + } + + return { error: true, workspaceSlug }; + } catch (error) { + // If it's a redirect, rethrow it + if (error instanceof Response) { + throw error; + } + // Otherwise return error state + return { error: true, workspaceSlug }; + } +} + +export default function IssueDetailsPage({ loaderData }: Route.ComponentProps) { const router = useAppRouter(); const { t } = useTranslation(); - const { workspaceSlug, projectId, issueId } = useParams(); const { resolvedTheme } = useTheme(); - const { data, isLoading, error } = useSWR( - workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_META_${workspaceSlug}_${projectId}_${issueId}` : null, - workspaceSlug && projectId && issueId - ? () => issueService.getIssueMetaFromURL(workspaceSlug.toString(), projectId.toString(), issueId.toString()) - : null - ); - - useEffect(() => { - if (data) { - router.push(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`); - } - }, [workspaceSlug, data]); - - return ( -
- {error ? ( + if (loaderData.error) { + return ( +
router.push(`/${workspaceSlug}/workspace-views/all-issues/`), + onClick: () => router.push(`/${loaderData.workspaceSlug}/workspace-views/all-issues/`), }} /> - ) : isLoading ? ( - <> - - - ) : ( - <> - )} +
+ ); + } + + return ( +
+
); -}); - -export default IssueDetailsPage; +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx index c2df9c4e52..ffaf086c03 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx @@ -1,16 +1,19 @@ "use client"; // components +import { Outlet } from "react-router"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { ProjectIssuesHeader } from "./header"; import { ProjectIssuesMobileHeader } from "./mobile-header"; -export default function ProjectIssuesLayout({ children }: { children: React.ReactNode }) { +export default function ProjectIssuesLayout() { return ( <> } mobileHeader={} /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx index 0aa774a74a..c714cfaf1f 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx @@ -3,11 +3,12 @@ import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { ChevronDown } from "lucide-react"; // plane imports import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types"; +import { ChevronDownIcon } from "@plane/propel/icons"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; // components import { WorkItemsModal } from "@/components/analytics/work-items/modal"; import { @@ -78,7 +79,7 @@ export const ProjectIssuesMobileHeader = observer(() => { menuButton={ {t("common.display")} - + } > diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx index 63162e102c..17955c562b 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx @@ -1,8 +1,6 @@ "use client"; import { observer } from "mobx-react"; -import Head from "next/head"; -import { useParams } from "next/navigation"; // i18n import { useTranslation } from "@plane/i18n"; // components @@ -10,35 +8,27 @@ import { PageHead } from "@/components/core/page-title"; import { ProjectLayoutRoot } from "@/components/issues/issue-layouts/roots/project-layout-root"; // hooks import { useProject } from "@/hooks/store/use-project"; +import type { Route } from "./+types/page"; -const ProjectIssuesPage = observer(() => { - const { projectId } = useParams(); +function ProjectIssuesPage({ params }: Route.ComponentProps) { + const { projectId } = params; // i18n const { t } = useTranslation(); // store const { getProjectById } = useProject(); - if (!projectId) { - return <>; - } - // derived values - const project = getProjectById(projectId.toString()); + const project = getProjectById(projectId); const pageTitle = project?.name ? `${project?.name} - ${t("issue.label", { count: 2 })}` : undefined; // Count is for pluralization return ( <> - - - {project?.name} - {t("issue.label", { count: 2 })} - -
); -}); +} -export default ProjectIssuesPage; +export default observer(ProjectIssuesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx index 5b1fa1b186..bcb1501407 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx @@ -1,27 +1,27 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import useSWR from "swr"; -// components +// plane imports import { cn } from "@plane/utils"; +// assets +import emptyModule from "@/app/assets/empty-state/module.svg?url"; +// components import { EmptyState } from "@/components/common/empty-state"; import { PageHead } from "@/components/core/page-title"; import { ModuleLayoutRoot } from "@/components/issues/issue-layouts/roots/module-layout-root"; import { ModuleAnalyticsSidebar } from "@/components/modules"; -// helpers // hooks import { useModule } from "@/hooks/store/use-module"; import { useProject } from "@/hooks/store/use-project"; import { useAppRouter } from "@/hooks/use-app-router"; import useLocalStorage from "@/hooks/use-local-storage"; -// assets -import emptyModule from "@/public/empty-state/module.svg"; +import type { Route } from "./+types/page"; -const ModuleIssuesPage = observer(() => { +function ModuleIssuesPage({ params }: Route.ComponentProps) { // router const router = useAppRouter(); - const { workspaceSlug, projectId, moduleId } = useParams(); + const { workspaceSlug, projectId, moduleId } = params; // store hooks const { fetchModuleDetails, getModuleById } = useModule(); const { getProjectById } = useProject(); @@ -30,25 +30,19 @@ const ModuleIssuesPage = observer(() => { const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; // fetching module details - const { error } = useSWR( - workspaceSlug && projectId && moduleId ? `CURRENT_MODULE_DETAILS_${moduleId.toString()}` : null, - workspaceSlug && projectId && moduleId - ? () => fetchModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString()) - : null + const { error } = useSWR(`CURRENT_MODULE_DETAILS_${moduleId}`, () => + fetchModuleDetails(workspaceSlug, projectId, moduleId) ); // derived values - const projectModule = moduleId ? getModuleById(moduleId.toString()) : undefined; - const project = projectId ? getProjectById(projectId.toString()) : undefined; + const projectModule = getModuleById(moduleId); + const project = getProjectById(projectId); const pageTitle = project?.name && projectModule?.name ? `${project?.name} - ${projectModule?.name}` : undefined; const toggleSidebar = () => { setValue(`${!isSidebarCollapsed}`); }; - if (!workspaceSlug || !projectId || !moduleId) return <>; - // const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; - return ( <> @@ -67,7 +61,7 @@ const ModuleIssuesPage = observer(() => {
- {moduleId && !isSidebarCollapsed && ( + {!isSidebarCollapsed && (
{ "0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)", }} > - +
)}
)} ); -}); +} -export default ModuleIssuesPage; +export default observer(ModuleIssuesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx index e2520b8a2f..2dec9fa964 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -17,13 +17,8 @@ import { import { Button } from "@plane/propel/button"; import { ModuleIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { - EIssuesStoreType, - ICustomSearchSelectOption, - IIssueDisplayFilterOptions, - IIssueDisplayProperties, - EIssueLayoutTypes, -} from "@plane/types"; +import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; import { cn } from "@plane/utils"; // components diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx index e976c73535..4e8e0e60bf 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx @@ -1,16 +1,19 @@ "use client"; +import { Outlet } from "react-router"; // components import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { ModuleIssuesHeader } from "./header"; import { ModuleIssuesMobileHeader } from "./mobile-header"; -export default function ProjectModuleIssuesLayout({ children }: { children: React.ReactNode }) { +export default function ProjectModuleIssuesLayout() { return ( <> } mobileHeader={} /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx index 8175157005..6be0088d63 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx @@ -3,12 +3,12 @@ import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -// icons -import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; // plane imports import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types"; +import { CalendarLayoutIcon, BoardLayoutIcon, ListLayoutIcon, ChevronDownIcon } from "@plane/propel/icons"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; import { CustomMenu } from "@plane/ui"; // components import { WorkItemsModal } from "@/components/analytics/work-items/modal"; @@ -20,9 +20,9 @@ import { useModule } from "@/hooks/store/use-module"; import { useProject } from "@/hooks/store/use-project"; const SUPPORTED_LAYOUTS = [ - { key: "list", i18n_title: "issue.layouts.list", icon: List }, - { key: "kanban", i18n_title: "issue.layouts.kanban", icon: Kanban }, - { key: "calendar", i18n_title: "issue.layouts.calendar", icon: Calendar }, + { key: "list", i18n_title: "issue.layouts.list", icon: ListLayoutIcon }, + { key: "kanban", i18n_title: "issue.layouts.kanban", icon: BoardLayoutIcon }, + { key: "calendar", i18n_title: "issue.layouts.calendar", icon: CalendarLayoutIcon }, ]; export const ModuleIssuesMobileHeader = observer(() => { @@ -107,7 +107,7 @@ export const ModuleIssuesMobileHeader = observer(() => { menuButton={ Display - + } > diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx index 269cf94557..781de0480a 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx @@ -1,16 +1,19 @@ "use client"; +import { Outlet } from "react-router"; // components import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { ModulesListHeader } from "./header"; import { ModulesListMobileHeader } from "./mobile-header"; -export default function ProjectModulesListLayout({ children }: { children: React.ReactNode }) { +export default function ProjectModulesListLayout() { return ( <> } mobileHeader={} /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx index 138867e4f0..94b1904574 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx @@ -1,9 +1,9 @@ "use client"; import { observer } from "mobx-react"; -import { ChevronDown } from "lucide-react"; import { MODULE_VIEW_LAYOUTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { ChevronDownIcon } from "@plane/propel/icons"; import { CustomMenu, Row } from "@plane/ui"; import { ModuleLayoutIcon } from "@/components/modules"; import { useModuleFilter } from "@/hooks/store/use-module-filter"; @@ -22,7 +22,7 @@ export const ModulesListMobileHeader = observer(() => { // placement="bottom-start" customButton={ - Layout + Layout } customButtonClassName="flex flex-grow justify-center items-center text-custom-text-200 text-sm" diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx index cd55d42f25..65b96d9ba9 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx @@ -2,56 +2,63 @@ import { useCallback } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// types +import { useTheme } from "next-themes"; +// plane imports import { EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EUserProjectRoles, TModuleFilters } from "@plane/types"; -// components +import type { TModuleFilters } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; import { calculateTotalFilters } from "@plane/utils"; +// assets +import darkModulesAsset from "@/app/assets/empty-state/disabled-feature/modules-dark.webp?url"; +import lightModulesAsset from "@/app/assets/empty-state/disabled-feature/modules-light.webp?url"; +// components import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; -// helpers // hooks import { useModuleFilter } from "@/hooks/store/use-module-filter"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import type { Route } from "./+types/page"; -const ProjectModulesPage = observer(() => { +function ProjectModulesPage({ params }: Route.ComponentProps) { // router const router = useAppRouter(); - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug, projectId } = params; + // theme hook + const { resolvedTheme } = useTheme(); // plane hooks const { t } = useTranslation(); // store const { getProjectById, currentProjectDetails } = useProject(); - const { currentProjectFilters, currentProjectDisplayFilters, clearAllFilters, updateFilters, updateDisplayFilters } = - useModuleFilter(); + const { + currentProjectFilters = {}, + currentProjectDisplayFilters, + clearAllFilters, + updateFilters, + updateDisplayFilters, + } = useModuleFilter(); const { allowPermissions } = useUserPermissions(); // derived values - const project = projectId ? getProjectById(projectId.toString()) : undefined; + const project = getProjectById(projectId); const pageTitle = project?.name ? `${project?.name} - Modules` : undefined; const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/modules" }); + const resolvedPath = resolvedTheme === "light" ? lightModulesAsset : darkModulesAsset; const handleRemoveFilter = useCallback( (key: keyof TModuleFilters, value: string | null) => { - if (!projectId) return; - let newValues = currentProjectFilters?.[key] ?? []; + let newValues = currentProjectFilters[key] ?? []; if (!value) newValues = []; else newValues = newValues.filter((val) => val !== value); - updateFilters(projectId.toString(), { [key]: newValues }); + updateFilters(projectId, { [key]: newValues }); }, [currentProjectFilters, projectId, updateFilters] ); - if (!workspaceSlug || !projectId) return <>; - // No access to if (currentProjectDetails?.module_view === false) return ( @@ -75,16 +82,13 @@ const ProjectModulesPage = observer(() => { <>
- {(calculateTotalFilters(currentProjectFilters ?? {}) !== 0 || currentProjectDisplayFilters?.favorites) && ( + {(calculateTotalFilters(currentProjectFilters) !== 0 || currentProjectDisplayFilters?.favorites) && ( clearAllFilters(`${projectId}`)} + handleClearAllFilters={() => clearAllFilters(projectId)} handleRemoveFilter={handleRemoveFilter} - handleDisplayFiltersUpdate={(val) => { - if (!projectId) return; - updateDisplayFilters(projectId.toString(), val); - }} + handleDisplayFiltersUpdate={(val) => updateDisplayFilters(projectId, val)} alwaysAllowEditing /> )} @@ -92,6 +96,6 @@ const ProjectModulesPage = observer(() => {
); -}); +} -export default ProjectModulesPage; +export default observer(ProjectModulesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 761dc75600..6fdd3a1c4e 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -3,11 +3,11 @@ import { useCallback, useEffect, useMemo } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useParams } from "next/navigation"; import useSWR from "swr"; // plane types import { getButtonStyling } from "@plane/propel/button"; -import { EFileAssetType, TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types"; +import type { TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types"; +import { EFileAssetType } from "@plane/types"; // plane ui // plane utils import { cn } from "@plane/utils"; @@ -15,7 +15,8 @@ import { cn } from "@plane/utils"; import { LogoSpinner } from "@/components/common/logo-spinner"; import { PageHead } from "@/components/core/page-title"; import { IssuePeekOverview } from "@/components/issues/peek-overview"; -import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages/editor/page-root"; +import type { TPageRootConfig, TPageRootHandlers } from "@/components/pages/editor/page-root"; +import { PageRoot } from "@/components/pages/editor/page-root"; // hooks import { useEditorConfig } from "@/hooks/editor"; import { useEditorAsset } from "@/hooks/store/use-editor-asset"; @@ -27,33 +28,34 @@ import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; import { WorkspaceService } from "@/plane-web/services"; // services import { ProjectPageService, ProjectPageVersionService } from "@/services/page"; +import type { Route } from "./+types/page"; const workspaceService = new WorkspaceService(); const projectPageService = new ProjectPageService(); const projectPageVersionService = new ProjectPageVersionService(); const storeType = EPageStoreType.PROJECT; -const PageDetailsPage = observer(() => { +function PageDetailsPage({ params }: Route.ComponentProps) { // router const router = useAppRouter(); - const { workspaceSlug, projectId, pageId } = useParams(); + const { workspaceSlug, projectId, pageId } = params; // store hooks const { createPage, fetchPageDetails } = usePageStore(storeType); const page = usePage({ - pageId: pageId?.toString() ?? "", + pageId, storeType, }); const { getWorkspaceBySlug } = useWorkspace(); const { uploadEditorAsset } = useEditorAsset(); // derived values - const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : ""; + const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug)?.id ?? "") : ""; const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {}; // entity search handler const fetchEntityCallback = useCallback( async (payload: TSearchEntityRequestPayload) => - await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + await workspaceService.searchEntity(workspaceSlug, { ...payload, - project_id: projectId?.toString() ?? "", + project_id: projectId, }), [projectId, workspaceSlug] ); @@ -61,10 +63,8 @@ const PageDetailsPage = observer(() => { const { getEditorFileHandlers } = useEditorConfig(); // fetch page details const { error: pageDetailsError } = useSWR( - workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null, - workspaceSlug && projectId && pageId - ? () => fetchPageDetails(workspaceSlug?.toString(), projectId?.toString(), pageId.toString()) - : null, + `PAGE_DETAILS_${pageId}`, + () => fetchPageDetails(workspaceSlug, projectId, pageId), { revalidateIfStale: true, revalidateOnFocus: true, @@ -75,33 +75,17 @@ const PageDetailsPage = observer(() => { const pageRootHandlers: TPageRootHandlers = useMemo( () => ({ create: createPage, - fetchAllVersions: async (pageId) => { - if (!workspaceSlug || !projectId) return; - return await projectPageVersionService.fetchAllVersions(workspaceSlug.toString(), projectId.toString(), pageId); - }, + fetchAllVersions: async (pageId) => + await projectPageVersionService.fetchAllVersions(workspaceSlug, projectId, pageId), fetchDescriptionBinary: async () => { - if (!workspaceSlug || !projectId || !id) return; - return await projectPageService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), id); + if (!id) return; + return await projectPageService.fetchDescriptionBinary(workspaceSlug, projectId, id); }, fetchEntity: fetchEntityCallback, - fetchVersionDetails: async (pageId, versionId) => { - if (!workspaceSlug || !projectId) return; - return await projectPageVersionService.fetchVersionById( - workspaceSlug.toString(), - projectId.toString(), - pageId, - versionId - ); - }, - restoreVersion: async (pageId, versionId) => { - if (!workspaceSlug || !projectId) return; - await projectPageVersionService.restoreVersion( - workspaceSlug.toString(), - projectId.toString(), - pageId, - versionId - ); - }, + fetchVersionDetails: async (pageId, versionId) => + await projectPageVersionService.fetchVersionById(workspaceSlug, projectId, pageId, versionId), + restoreVersion: async (pageId, versionId) => + await projectPageVersionService.restoreVersion(workspaceSlug, projectId, pageId, versionId), getRedirectionLink: (pageId) => { if (pageId) { return `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`; @@ -117,7 +101,7 @@ const PageDetailsPage = observer(() => { const pageRootConfig: TPageRootConfig = useMemo( () => ({ fileHandler: getEditorFileHandlers({ - projectId: projectId?.toString() ?? "", + projectId, uploadFile: async (blockId, file) => { const { asset_id } = await uploadEditorAsset({ blockId, @@ -126,13 +110,13 @@ const PageDetailsPage = observer(() => { entity_type: EFileAssetType.PAGE_DESCRIPTION, }, file, - projectId: projectId?.toString() ?? "", - workspaceSlug: workspaceSlug?.toString() ?? "", + projectId, + workspaceSlug, }); return asset_id; }, workspaceId, - workspaceSlug: workspaceSlug?.toString() ?? "", + workspaceSlug, }), }), [getEditorFileHandlers, id, uploadEditorAsset, projectId, workspaceId, workspaceSlug] @@ -141,8 +125,8 @@ const PageDetailsPage = observer(() => { const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo( () => ({ documentType: "project_page", - projectId: projectId?.toString() ?? "", - workspaceSlug: workspaceSlug?.toString() ?? "", + projectId, + workspaceSlug, }), [projectId, workspaceSlug] ); @@ -176,7 +160,7 @@ const PageDetailsPage = observer(() => {
); - if (!page || !workspaceSlug || !projectId) return null; + if (!page) return null; return ( <> @@ -189,14 +173,14 @@ const PageDetailsPage = observer(() => { storeType={storeType} page={page} webhookConnectionParams={webhookConnectionParams} - workspaceSlug={workspaceSlug.toString()} - projectId={projectId?.toString()} + workspaceSlug={workspaceSlug} + projectId={projectId} />
); -}); +} -export default PageDetailsPage; +export default observer(PageDetailsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index 8f4eeadf06..ad6b9c1285 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -4,7 +4,7 @@ import { useParams } from "next/navigation"; import { EProjectFeatureKey } from "@plane/constants"; import { PageIcon } from "@plane/propel/icons"; // types -import { ICustomSearchSelectOption } from "@plane/types"; +import type { ICustomSearchSelectOption } from "@plane/types"; // ui import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; // components diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx index a9147e0fe1..a446165d0d 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx @@ -1,27 +1,27 @@ "use client"; // component -import { useParams } from "next/navigation"; +import { Outlet } from "react-router"; import useSWR from "swr"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; // plane web hooks import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; // local components +import type { Route } from "./+types/layout"; import { PageDetailsHeader } from "./header"; -export default function ProjectPageDetailsLayout({ children }: { children: React.ReactNode }) { - const { workspaceSlug, projectId } = useParams(); +export default function ProjectPageDetailsLayout({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; const { fetchPagesList } = usePageStore(EPageStoreType.PROJECT); // fetching pages list - useSWR( - workspaceSlug && projectId ? `PROJECT_PAGES_${projectId}` : null, - workspaceSlug && projectId ? () => fetchPagesList(workspaceSlug.toString(), projectId.toString()) : null - ); + useSWR(`PROJECT_PAGES_${projectId}`, () => fetchPagesList(workspaceSlug, projectId)); return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx index 086a5ce317..4aa12716ba 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx @@ -13,7 +13,7 @@ import { // plane types import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { TPage } from "@plane/types"; +import type { TPage } from "@plane/types"; // plane ui import { Breadcrumbs, Header } from "@plane/ui"; // helpers diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx index a74a9797b3..64f4ee95de 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx @@ -1,17 +1,19 @@ "use client"; -import { ReactNode } from "react"; // components +import { Outlet } from "react-router"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; // local components import { PagesListHeader } from "./header"; -export default function ProjectPagesListLayout({ children }: { children: ReactNode }) { +export default function ProjectPagesListLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index 756a2dac64..775472f82b 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -1,11 +1,16 @@ "use client"; import { observer } from "mobx-react"; -import { useParams, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; // plane imports import { EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EUserProjectRoles, TPageNavigationTabs } from "@plane/types"; +import type { TPageNavigationTabs } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; +// assets +import darkPagesAsset from "@/app/assets/empty-state/disabled-feature/pages-dark.webp?url"; +import lightPagesAsset from "@/app/assets/empty-state/disabled-feature/pages-light.webp?url"; // components import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; @@ -15,35 +20,35 @@ import { PagesListView } from "@/components/pages/pages-list-view"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; // plane web hooks import { EPageStoreType } from "@/plane-web/hooks/store"; +import type { Route } from "./+types/page"; -const ProjectPagesPage = observer(() => { +const getPageType = (pageType?: string | null): TPageNavigationTabs => { + if (pageType === "private") return "private"; + if (pageType === "archived") return "archived"; + return "public"; +}; + +function ProjectPagesPage({ params }: Route.ComponentProps) { // router const router = useAppRouter(); const searchParams = useSearchParams(); const type = searchParams.get("type"); - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug, projectId } = params; + // theme hook + const { resolvedTheme } = useTheme(); // plane hooks const { t } = useTranslation(); // store hooks const { getProjectById, currentProjectDetails } = useProject(); const { allowPermissions } = useUserPermissions(); // derived values - const project = projectId ? getProjectById(projectId.toString()) : undefined; + const project = getProjectById(projectId); const pageTitle = project?.name ? `${project?.name} - Pages` : undefined; const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/pages" }); - - const currentPageType = (): TPageNavigationTabs => { - const pageType = type?.toString(); - if (pageType === "private") return "private"; - if (pageType === "archived") return "archived"; - return "public"; - }; - - if (!workspaceSlug || !projectId) return <>; + const resolvedPath = resolvedTheme === "light" ? lightPagesAsset : darkPagesAsset; + const pageType = getPageType(type); // No access to cycle if (currentProjectDetails?.page_view === false) @@ -67,15 +72,15 @@ const ProjectPagesPage = observer(() => { <> - + ); -}); +} -export default ProjectPagesPage; +export default observer(ProjectPagesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx index 6cbee5bd42..33a84241ab 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -17,14 +17,8 @@ import { import { Button } from "@plane/propel/button"; import { ViewsIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { - EIssuesStoreType, - EViewAccess, - ICustomSearchSelectOption, - IIssueDisplayFilterOptions, - IIssueDisplayProperties, - EIssueLayoutTypes, -} from "@plane/types"; +import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EViewAccess, EIssueLayoutTypes } from "@plane/types"; // ui import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; // components diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx index a5342d81ae..4d3c3a183e 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx @@ -1,8 +1,9 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import useSWR from "swr"; +// assets +import emptyView from "@/app/assets/empty-state/view.svg?url"; // components import { EmptyState } from "@/components/common/empty-state"; import { PageHead } from "@/components/core/page-title"; @@ -10,28 +11,22 @@ import { ProjectViewLayoutRoot } from "@/components/issues/issue-layouts/roots/p // hooks import { useProject } from "@/hooks/store/use-project"; import { useProjectView } from "@/hooks/store/use-project-view"; -// assets import { useAppRouter } from "@/hooks/use-app-router"; -import emptyView from "@/public/empty-state/view.svg"; +import type { Route } from "./+types/page"; -const ProjectViewIssuesPage = observer(() => { +function ProjectViewIssuesPage({ params }: Route.ComponentProps) { // router const router = useAppRouter(); - const { workspaceSlug, projectId, viewId } = useParams(); + const { workspaceSlug, projectId, viewId } = params; // store hooks const { fetchViewDetails, getViewById } = useProjectView(); const { getProjectById } = useProject(); // derived values - const projectView = viewId ? getViewById(viewId.toString()) : undefined; - const project = projectId ? getProjectById(projectId.toString()) : undefined; + const projectView = getViewById(viewId); + const project = getProjectById(projectId); const pageTitle = project?.name && projectView?.name ? `${project?.name} - ${projectView?.name}` : undefined; - const { error } = useSWR( - workspaceSlug && projectId && viewId ? `VIEW_DETAILS_${viewId.toString()}` : null, - workspaceSlug && projectId && viewId - ? () => fetchViewDetails(workspaceSlug.toString(), projectId.toString(), viewId.toString()) - : null - ); + const { error } = useSWR(`VIEW_DETAILS_${viewId}`, () => fetchViewDetails(workspaceSlug, projectId, viewId)); if (error) { return ( @@ -53,6 +48,6 @@ const ProjectViewIssuesPage = observer(() => { ); -}); +} -export default ProjectViewIssuesPage; +export default observer(ProjectViewIssuesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx index af6e57d49a..f7ad8f2122 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx @@ -1,15 +1,18 @@ "use client"; +import { Outlet } from "react-router"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; // local components import { ProjectViewIssuesHeader } from "./[viewId]/header"; -export default function ProjectViewIssuesLayout({ children }: { children: React.ReactNode }) { +export default function ProjectViewIssuesLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx index d96c8256a5..c6fc4039c0 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx @@ -1,16 +1,19 @@ "use client"; +import { Outlet } from "react-router"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; // local components import { ProjectViewsHeader } from "./header"; import { ViewMobileHeader } from "./mobile-header"; -export default function ProjectViewsListLayout({ children }: { children: React.ReactNode }) { +export default function ProjectViewsListLayout() { return ( <> } mobileHeader={} /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx index e9ef19d1f9..3980e977fe 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx @@ -2,7 +2,8 @@ import { observer } from "mobx-react"; // icons -import { ChevronDown, ListFilter } from "lucide-react"; +import { ListFilter } from "lucide-react"; +import { ChevronDownIcon } from "@plane/propel/icons"; // components import { Row } from "@plane/ui"; import { FiltersDropdown } from "@/components/issues/issue-layouts/filters"; @@ -42,7 +43,7 @@ export const ViewMobileHeader = observer(() => { menuButton={ Filters - + } > diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx index 1779726c1c..b68a395bdb 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx @@ -2,30 +2,35 @@ import { useCallback } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// components +import { useTheme } from "next-themes"; +// plane imports import { EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EUserProjectRoles, EViewAccess, TViewFilterProps } from "@plane/types"; +import type { EViewAccess, TViewFilterProps } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; import { Header, EHeaderVariant } from "@plane/ui"; import { calculateTotalFilters } from "@plane/utils"; +// assets +import darkViewsAsset from "@/app/assets/empty-state/disabled-feature/views-dark.webp?url"; +import lightViewsAsset from "@/app/assets/empty-state/disabled-feature/views-light.webp?url"; +// components import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; import { ViewAppliedFiltersList } from "@/components/views/applied-filters"; import { ProjectViewsList } from "@/components/views/views-list"; -// constants -// helpers // hooks import { useProject } from "@/hooks/store/use-project"; import { useProjectView } from "@/hooks/store/use-project-view"; import { useUserPermissions } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import type { Route } from "./+types/page"; -const ProjectViewsPage = observer(() => { +function ProjectViewsPage({ params }: Route.ComponentProps) { // router const router = useAppRouter(); - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug, projectId } = params; + // theme hook + const { resolvedTheme } = useTheme(); // plane hooks const { t } = useTranslation(); // store @@ -33,10 +38,10 @@ const ProjectViewsPage = observer(() => { const { filters, updateFilters, clearAllFilters } = useProjectView(); const { allowPermissions } = useUserPermissions(); // derived values - const project = projectId ? getProjectById(projectId.toString()) : undefined; + const project = getProjectById(projectId); const pageTitle = project?.name ? `${project?.name} - Views` : undefined; const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/views" }); + const resolvedPath = resolvedTheme === "light" ? lightViewsAsset : darkViewsAsset; const handleRemoveFilter = useCallback( (key: keyof TViewFilterProps, value: string | EViewAccess | null) => { @@ -57,8 +62,6 @@ const ProjectViewsPage = observer(() => { const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0; - if (!workspaceSlug || !projectId) return <>; - // No access to if (currentProjectDetails?.issue_views_view === false) return ( @@ -94,6 +97,6 @@ const ProjectViewsPage = observer(() => { ); -}); +} -export default ProjectViewsPage; +export default observer(ProjectViewsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx index d8429ef306..c2c678c772 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx @@ -1,17 +1,20 @@ "use client"; -import { ReactNode } from "react"; // components +import { Outlet } from "react-router"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; // local components import { ProjectsListHeader } from "@/plane-web/components/projects/header"; import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header"; -export default function ProjectListLayout({ children }: { children: ReactNode }) { + +export default function ProjectListLayout() { return ( <> } mobileHeader={} /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx index 8e1d194356..4053aa6c73 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx @@ -1,18 +1,16 @@ "use client"; -import { ReactNode } from "react"; -import { useParams } from "next/navigation"; +import { Outlet } from "react-router"; // plane web layouts import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; +import type { Route } from "./+types/layout"; -const ProjectDetailLayout = ({ children }: { children: ReactNode }) => { +export default function ProjectDetailLayout({ params }: Route.ComponentProps) { // router - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug, projectId } = params; return ( - - {children} + + ); -}; - -export default ProjectDetailLayout; +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx index d8429ef306..4e9249ed7b 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx @@ -1,17 +1,20 @@ "use client"; -import { ReactNode } from "react"; +import { Outlet } from "react-router"; // components import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; // local components import { ProjectsListHeader } from "@/plane-web/components/projects/header"; import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header"; -export default function ProjectListLayout({ children }: { children: ReactNode }) { + +export default function ProjectListLayout() { return ( <> } mobileHeader={} /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx index e82016f3df..a30707e218 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { isEmpty } from "lodash-es"; import { observer } from "mobx-react"; // plane helpers diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx index 1573e75295..f65ea3b730 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx @@ -5,11 +5,11 @@ import { useTheme } from "next-themes"; // plane imports import { HEADER_GITHUB_ICON, GITHUB_REDIRECTED_TRACKER_EVENT } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +// assets +import githubBlackImage from "@/app/assets/logos/github-black.png?url"; +import githubWhiteImage from "@/app/assets/logos/github-white.png?url"; // helpers import { captureElementAndEvent } from "@/helpers/event-tracker.helper"; -// public imports -import githubBlackImage from "@/public/logos/github-black.png"; -import githubWhiteImage from "@/public/logos/github-white.png"; export const StarUsOnGitHubLink = () => { // plane hooks diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx index d2abcd3f6d..366a71ab7a 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx @@ -1,14 +1,17 @@ "use client"; +import { Outlet } from "react-router"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { WorkspaceStickyHeader } from "./header"; -export default function WorkspaceStickiesLayout({ children }: { children: React.ReactNode }) { +export default function WorkspaceStickiesLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx index 9419f2b318..7f2c4abe99 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // plane imports import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants"; // components @@ -10,10 +9,11 @@ import { PageHead } from "@/components/core/page-title"; import { AllIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/all-issue-layout-root"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; +import type { Route } from "./+types/page"; -const GlobalViewIssuesPage = observer(() => { +function GlobalViewIssuesPage({ params }: Route.ComponentProps) { // router - const { globalViewId } = useParams(); + const { globalViewId } = params; // store hooks const { currentWorkspace } = useWorkspace(); // states @@ -31,6 +31,6 @@ const GlobalViewIssuesPage = observer(() => { ); -}); +} -export default GlobalViewIssuesPage; +export default observer(GlobalViewIssuesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx index 7ecdd50c49..07f75a3098 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -13,13 +13,8 @@ import { import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { ViewsIcon } from "@plane/propel/icons"; -import { - EIssuesStoreType, - IIssueDisplayFilterOptions, - IIssueDisplayProperties, - ICustomSearchSelectOption, - EIssueLayoutTypes, -} from "@plane/types"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, ICustomSearchSelectOption } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx index 6f3dbe1b9c..95469c3d05 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx @@ -1,14 +1,17 @@ "use client"; +import { Outlet } from "react-router"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { GlobalIssuesHeader } from "./header"; -export default function GlobalIssuesLayout({ children }: { children: React.ReactNode }) { +export default function GlobalIssuesLayout() { return ( <> } /> - {children} + + + ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx index 5a3969a225..9f8865077e 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx @@ -14,7 +14,7 @@ import { GlobalViewsList } from "@/components/workspace/views/views-list"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; -const WorkspaceViewsPage = observer(() => { +function WorkspaceViewsPage() { const [query, setQuery] = useState(""); // store const { currentWorkspace } = useWorkspace(); @@ -47,6 +47,6 @@ const WorkspaceViewsPage = observer(() => {
); -}); +} -export default WorkspaceViewsPage; +export default observer(WorkspaceViewsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx index a87d4d2676..ea1745d75e 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx @@ -1,23 +1,27 @@ "use client"; -import { CommandPalette } from "@/components/command-palette"; +import { Outlet } from "react-router"; +// components import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider"; import { SettingsHeader } from "@/components/settings/header"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; -export default function SettingsLayout({ children }: { children: React.ReactNode }) { +export default function SettingsLayout() { return ( - +
{/* Header */} {/* Content */} -
{children}
+
+ +
diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx index 6c8950b4cb..6eeb9e667a 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx @@ -12,7 +12,7 @@ import { useUserPermissions } from "@/hooks/store/user"; // plane web components import { BillingRoot } from "@/plane-web/components/workspace/billing"; -const BillingSettingsPage = observer(() => { +function BillingSettingsPage() { // store hooks const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { currentWorkspace } = useWorkspace(); @@ -30,6 +30,6 @@ const BillingSettingsPage = observer(() => { ); -}); +} -export default BillingSettingsPage; +export default observer(BillingSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx index b0ba8774a9..e637dfb4f8 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx @@ -15,7 +15,7 @@ import SettingsHeading from "@/components/settings/heading"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserPermissions } from "@/hooks/store/user"; -const ExportsPage = observer(() => { +function ExportsPage() { // store hooks const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { currentWorkspace } = useWorkspace(); @@ -51,6 +51,6 @@ const ExportsPage = observer(() => {
); -}); +} -export default ExportsPage; +export default observer(ExportsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx index 2838e2226f..38585a77ec 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx @@ -12,7 +12,7 @@ import { SettingsHeading } from "@/components/settings/heading"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserPermissions } from "@/hooks/store/user"; -const ImportsPage = observer(() => { +function ImportsPage() { // router // store hooks const { currentWorkspace } = useWorkspace(); @@ -32,6 +32,6 @@ const ImportsPage = observer(() => { ); -}); +} -export default ImportsPage; +export default observer(ImportsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx index bb9d8f3d39..a538a44869 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx @@ -1,6 +1,5 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import useSWR from "swr"; // components import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; @@ -20,9 +19,7 @@ import { IntegrationService } from "@/services/integrations"; const integrationService = new IntegrationService(); -const WorkspaceIntegrationsPage = observer(() => { - // router - const { workspaceSlug } = useParams(); +function WorkspaceIntegrationsPage() { // store hooks const { currentWorkspace } = useWorkspace(); const { allowPermissions } = useUserPermissions(); @@ -30,8 +27,8 @@ const WorkspaceIntegrationsPage = observer(() => { // derived values const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined; - const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () => - workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null + const { data: appIntegrations } = useSWR(isAdmin ? APP_INTEGRATIONS : null, () => + isAdmin ? integrationService.getAppIntegrationsList() : null ); if (!isAdmin) return ; @@ -53,6 +50,6 @@ const WorkspaceIntegrationsPage = observer(() => { ); -}); +} -export default WorkspaceIntegrationsPage; +export default observer(WorkspaceIntegrationsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx index 6db9ea99b7..08ed35d80d 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx @@ -1,11 +1,11 @@ "use client"; -import { FC, ReactNode } from "react"; import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; +import { Outlet } from "react-router"; // constants import { WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; -import { EUserWorkspaceRoles } from "@plane/types"; +import type { EUserWorkspaceRoles } from "@plane/types"; // components import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper"; @@ -15,12 +15,7 @@ import { useUserPermissions } from "@/hooks/store/user"; // local components import { WorkspaceSettingsSidebar } from "./sidebar"; -export interface IWorkspaceSettingLayout { - children: ReactNode; -} - -const WorkspaceSettingLayout: FC = observer((props) => { - const { children } = props; +function WorkspaceSettingLayout() { // store hooks const { workspaceUserInfo, getWorkspaceRoleByWorkspaceSlug } = useUserPermissions(); // next hooks @@ -46,12 +41,14 @@ const WorkspaceSettingLayout: FC = observer((props) => ) : (
{}
-
{children}
+
+ +
)}
); -}); +} -export default WorkspaceSettingLayout; +export default observer(WorkspaceSettingLayout); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx index 892d85056f..20d3f57ee0 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import { Search } from "lucide-react"; // types import { @@ -14,7 +13,7 @@ import { import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { IWorkspaceBulkInviteFormData } from "@plane/types"; +import type { IWorkspaceBulkInviteFormData } from "@plane/types"; import { cn } from "@plane/utils"; // components import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; @@ -32,13 +31,14 @@ import { useUserPermissions } from "@/hooks/store/user"; // plane web components import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button"; import { SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members/invite-modal"; +import type { Route } from "./+types/page"; -const WorkspaceMembersSettingsPage = observer(() => { +function WorkspaceMembersSettingsPage({ params }: Route.ComponentProps) { // states const [inviteModal, setInviteModal] = useState(false); const [searchQuery, setSearchQuery] = useState(""); // router - const { workspaceSlug } = useParams(); + const { workspaceSlug } = params; // store hooks const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { @@ -54,39 +54,42 @@ const WorkspaceMembersSettingsPage = observer(() => { EUserPermissionsLevel.WORKSPACE ); - const handleWorkspaceInvite = (data: IWorkspaceBulkInviteFormData) => { - if (!workspaceSlug) return; + const handleWorkspaceInvite = async (data: IWorkspaceBulkInviteFormData) => { + try { + await inviteMembersToWorkspace(workspaceSlug, data); - return inviteMembersToWorkspace(workspaceSlug.toString(), data) - .then(() => { - setInviteModal(false); - captureSuccess({ - eventName: MEMBER_TRACKER_EVENTS.invite, - payload: { - emails: [...data.emails.map((email) => email.email)], - }, - }); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: t("workspace_settings.settings.members.invitations_sent_successfully"), - }); - }) - .catch((err) => { - captureError({ - eventName: MEMBER_TRACKER_EVENTS.invite, - payload: { - emails: [...data.emails.map((email) => email.email)], - }, - error: err, - }); - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: `${err.error ?? t("something_went_wrong_please_try_again")}`, - }); - throw err; + setInviteModal(false); + + captureSuccess({ + eventName: MEMBER_TRACKER_EVENTS.invite, + payload: { + emails: data.emails.map((email) => email.email), + }, }); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: t("workspace_settings.settings.members.invitations_sent_successfully"), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + captureError({ + eventName: MEMBER_TRACKER_EVENTS.invite, + payload: { + emails: data.emails.map((email) => email.email), + }, + error: err, + }); + + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${err.error ?? t("something_went_wrong_please_try_again")}`, + }); + + throw err; + } }; // Handler for role filter updates @@ -162,6 +165,6 @@ const WorkspaceMembersSettingsPage = observer(() => { ); -}); +} -export default WorkspaceMembersSettingsPage; +export default observer(WorkspaceMembersSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx index 12fcdca8f3..9f27cb7d08 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx @@ -10,7 +10,7 @@ import { WorkspaceDetails } from "@/components/workspace/settings/workspace-deta // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; -const WorkspaceSettingsPage = observer(() => { +function WorkspaceSettingsPage() { // store hooks const { currentWorkspace } = useWorkspace(); const { t } = useTranslation(); @@ -25,6 +25,6 @@ const WorkspaceSettingsPage = observer(() => { ); -}); +} -export default WorkspaceSettingsPage; +export default observer(WorkspaceSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx index 09a4de1d59..bcf088dd26 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx @@ -7,12 +7,12 @@ import { EUserPermissions, WORKSPACE_SETTINGS_CATEGORY, } from "@plane/constants"; -import { EUserWorkspaceRoles } from "@plane/types"; +import type { EUserWorkspaceRoles } from "@plane/types"; import { SettingsSidebar } from "@/components/settings/sidebar"; import { useUserPermissions } from "@/hooks/store/user"; import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; -const ICONS = { +export const WORKSPACE_SETTINGS_ICONS = { general: Building, members: Users, export: ArrowUpToLine, @@ -30,7 +30,7 @@ export const WorkspaceActionIcons = ({ className?: string; }) => { if (type === undefined) return null; - const Icon = ICONS[type as keyof typeof ICONS]; + const Icon = WORKSPACE_SETTINGS_ICONS[type as keyof typeof WORKSPACE_SETTINGS_ICONS]; if (!Icon) return null; return ; }; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx index 38d7571df8..f8801d1ca2 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx @@ -2,11 +2,10 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import useSWR from "swr"; import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { IWebhook } from "@plane/types"; +import type { IWebhook } from "@plane/types"; // ui // components import { LogoSpinner } from "@/components/common/logo-spinner"; @@ -18,12 +17,13 @@ import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; import { useWebhook } from "@/hooks/store/use-webhook"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserPermissions } from "@/hooks/store/user"; +import type { Route } from "./+types/page"; -const WebhookDetailsPage = observer(() => { +function WebhookDetailsPage({ params }: Route.ComponentProps) { // states const [deleteWebhookModal, setDeleteWebhookModal] = useState(false); // router - const { workspaceSlug, webhookId } = useParams(); + const { workspaceSlug, webhookId } = params; // mobx store const { currentWebhook, fetchWebhookById, updateWebhook } = useWebhook(); const { currentWorkspace } = useWorkspace(); @@ -33,57 +33,55 @@ const WebhookDetailsPage = observer(() => { // useEffect(() => { // if (isCreated !== "true") clearSecretKey(); // }, [clearSecretKey, isCreated]); - // derived values const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Webhook` : undefined; useSWR( - workspaceSlug && webhookId && isAdmin ? `WEBHOOK_DETAILS_${workspaceSlug}_${webhookId}` : null, - workspaceSlug && webhookId && isAdmin - ? () => fetchWebhookById(workspaceSlug.toString(), webhookId.toString()) - : null + isAdmin ? `WEBHOOK_DETAILS_${workspaceSlug}_${webhookId}` : null, + isAdmin ? () => fetchWebhookById(workspaceSlug, webhookId) : null ); const handleUpdateWebhook = async (formData: IWebhook) => { - if (!workspaceSlug || !formData || !formData.id) return; + if (!formData || !formData.id) return; + const payload = { - url: formData?.url, - is_active: formData?.is_active, - project: formData?.project, - cycle: formData?.cycle, - module: formData?.module, - issue: formData?.issue, - issue_comment: formData?.issue_comment, + url: formData.url, + is_active: formData.is_active, + project: formData.project, + cycle: formData.cycle, + module: formData.module, + issue: formData.issue, + issue_comment: formData.issue_comment, }; - await updateWebhook(workspaceSlug.toString(), formData.id, payload) - .then(() => { - captureSuccess({ - eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated, - payload: { - webhook: formData.id, - }, - }); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Webhook updated successfully.", - }); - }) - .catch((error) => { - captureError({ - eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated, - payload: { - webhook: formData.id, - }, - error: error as Error, - }); - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: error?.error ?? "Something went wrong. Please try again.", - }); + + try { + await updateWebhook(workspaceSlug, formData.id, payload); + + captureSuccess({ + eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated, + payload: { webhook: formData.id }, }); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Webhook updated successfully.", + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + captureError({ + eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated, + payload: { webhook: formData.id }, + error: error as Error, + }); + + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: error?.error ?? "Something went wrong. Please try again.", + }); + } }; if (!isAdmin) @@ -108,13 +106,13 @@ const WebhookDetailsPage = observer(() => { setDeleteWebhookModal(false)} />
-
- await handleUpdateWebhook(data)} data={currentWebhook} /> +
+
{currentWebhook && setDeleteWebhookModal(true)} />}
); -}); +} -export default WebhookDetailsPage; +export default observer(WebhookDetailsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx index 06be0fd854..eff5ce4d44 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx @@ -1,16 +1,15 @@ "use client"; -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import useSWR from "swr"; // plane imports import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // components +import { EmptyStateCompact } from "@plane/propel/empty-state"; import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { PageHead } from "@/components/core/page-title"; -import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { SettingsHeading } from "@/components/settings/heading"; import { WebhookSettingsLoader } from "@/components/ui/loader/settings/web-hook"; @@ -20,13 +19,13 @@ import { captureClick } from "@/helpers/event-tracker.helper"; import { useWebhook } from "@/hooks/store/use-webhook"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserPermissions } from "@/hooks/store/user"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import type { Route } from "./+types/page"; -const WebhooksListPage = observer(() => { +function WebhooksListPage({ params }: Route.ComponentProps) { // states const [showCreateWebhookModal, setShowCreateWebhookModal] = useState(false); // router - const { workspaceSlug } = useParams(); + const { workspaceSlug } = params; // plane hooks const { t } = useTranslation(); // mobx store @@ -35,11 +34,10 @@ const WebhooksListPage = observer(() => { const { currentWorkspace } = useWorkspace(); // derived values const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/webhooks" }); useSWR( - workspaceSlug && canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null, - workspaceSlug && canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug.toString()) : null + canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null, + canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug) : null ); const pageTitle = currentWorkspace?.name @@ -90,21 +88,23 @@ const WebhooksListPage = observer(() => { ) : (
- { - captureClick({ - elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_WEBHOOK_BUTTON, - }); - setShowCreateWebhookModal(true); + { + captureClick({ + elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_WEBHOOK_BUTTON, + }); + setShowCreateWebhookModal(true); + }, }, - }} + ]} + align="start" + rootClassName="py-20" />
@@ -112,6 +112,6 @@ const WebhooksListPage = observer(() => {
); -}); +} -export default WebhooksListPage; +export default observer(WebhooksListPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx index 799c29e17e..e76dcb1aab 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx @@ -2,29 +2,34 @@ import { useState } from "react"; import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; import { useTranslation } from "@plane/i18n"; // ui import { Button } from "@plane/propel/button"; +// assets +import darkActivityAsset from "@/app/assets/empty-state/profile/activity-dark.webp?url"; +import lightActivityAsset from "@/app/assets/empty-state/profile/activity-light.webp?url"; // components import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; import { ProfileActivityListPage } from "@/components/profile/activity/profile-activity-list"; // hooks import { SettingsHeading } from "@/components/settings/heading"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; const PER_PAGE = 100; -const ProfileActivityPage = observer(() => { +function ProfileActivityPage() { // states const [pageCount, setPageCount] = useState(1); const [totalPages, setTotalPages] = useState(0); const [resultsCount, setResultsCount] = useState(0); const [isEmpty, setIsEmpty] = useState(false); + // theme hook + const { resolvedTheme } = useTheme(); // plane hooks const { t } = useTranslation(); // derived values - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/profile/activity" }); + const resolvedPath = resolvedTheme === "light" ? lightActivityAsset : darkActivityAsset; const updateTotalPages = (count: number) => setTotalPages(count); @@ -84,6 +89,6 @@ const ProfileActivityPage = observer(() => { )} ); -}); +} -export default ProfileActivityPage; +export default observer(ProfileActivityPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx index d37711e38f..7b0663da9d 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx @@ -7,22 +7,21 @@ import useSWR from "swr"; import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // component +import { EmptyStateCompact } from "@plane/propel/empty-state"; import { APITokenService } from "@plane/services"; import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal"; import { ApiTokenListItem } from "@/components/api-token/token-list-item"; import { PageHead } from "@/components/core/page-title"; -import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; import { SettingsHeading } from "@/components/settings/heading"; import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token"; import { API_TOKENS_LIST } from "@/constants/fetch-keys"; // store hooks import { captureClick } from "@/helpers/event-tracker.helper"; import { useWorkspace } from "@/hooks/store/use-workspace"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; const apiTokenService = new APITokenService(); -const ApiTokensPage = observer(() => { +function ApiTokensPage() { // states const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false); // router @@ -30,8 +29,6 @@ const ApiTokensPage = observer(() => { const { t } = useTranslation(); // store hooks const { currentWorkspace } = useWorkspace(); - // derived values - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/api-tokens" }); const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list()); @@ -70,7 +67,7 @@ const ApiTokensPage = observer(() => {
) : ( -
+
{ }, }} /> -
- { captureClick({ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_PAT_BUTTON, }); setIsCreateTokenModalOpen(true); }, - }} - /> -
+ }, + ]} + align="start" + rootClassName="py-20" + />
)}
); -}); +} -export default ApiTokensPage; +export default observer(ApiTokensPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx index 4ca617cd5f..0678b884a5 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx @@ -1,8 +1,8 @@ "use client"; -import { ReactNode } from "react"; import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; +import { Outlet } from "react-router"; // components import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { getProfileActivePath } from "@/components/settings/helper"; @@ -10,12 +10,7 @@ import { SettingsMobileNav } from "@/components/settings/mobile"; // local imports import { ProfileSidebar } from "./sidebar"; -type Props = { - children: ReactNode; -}; - -const ProfileSettingsLayout = observer((props: Props) => { - const { children } = props; +function ProfileSettingsLayout() { // router const pathname = usePathname(); @@ -27,11 +22,13 @@ const ProfileSettingsLayout = observer((props: Props) => {
- {children} + + +
); -}); +} -export default ProfileSettingsLayout; +export default observer(ProfileSettingsLayout); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx index 238119334a..0fef26e1a7 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx @@ -9,7 +9,7 @@ import { ProfileForm } from "@/components/profile/form"; // hooks import { useUser } from "@/hooks/store/user"; -const ProfileSettingsPage = observer(() => { +function ProfileSettingsPage() { const { t } = useTranslation(); // store hooks const { data: currentUser, userProfile } = useUser(); @@ -27,6 +27,6 @@ const ProfileSettingsPage = observer(() => { ); -}); +} -export default ProfileSettingsPage; +export default observer(ProfileSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx index 5032ae1ba6..4ea14f70b4 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx @@ -13,7 +13,7 @@ import { SettingsHeading } from "@/components/settings/heading"; // hooks import { useUserProfile } from "@/hooks/store/user"; -const ProfileAppearancePage = observer(() => { +function ProfileAppearancePage() { const { t } = useTranslation(); // hooks const { data: userProfile } = useUserProfile(); @@ -44,6 +44,6 @@ const ProfileAppearancePage = observer(() => { )} ); -}); +} -export default ProfileAppearancePage; +export default observer(ProfileAppearancePage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx index 179559eae0..fad026ddae 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx @@ -15,7 +15,8 @@ import { getPasswordStrength } from "@plane/utils"; import { PageHead } from "@/components/core/page-title"; import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; // helpers -import { authErrorHandler, type EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; +import { authErrorHandler } from "@/helpers/authentication.helper"; +import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; // hooks import { useUser } from "@/hooks/store/user"; // services @@ -41,7 +42,7 @@ const defaultShowPassword = { confirmPassword: false, }; -const SecurityPage = observer(() => { +function SecurityPage() { // store const { data: currentUser, changePassword } = useUser(); // states @@ -254,6 +255,6 @@ const SecurityPage = observer(() => { ); -}); +} -export default SecurityPage; +export default observer(SecurityPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx new file mode 100644 index 0000000000..8e62377f3e --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { observer } from "mobx-react"; +import { Outlet } from "react-router"; +// plane web imports +import { AutomationsListWrapper } from "@/plane-web/components/automations/list/wrapper"; +import type { Route } from "./+types/layout"; + +function AutomationsListLayout({ params }: Route.ComponentProps) { + const { projectId, workspaceSlug } = params; + + return ( + + + + ); +} + +export default observer(AutomationsListLayout); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx index e9c15bf722..2e2664ff45 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx @@ -1,12 +1,10 @@ "use client"; -import React from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { IProject } from "@plane/types"; +import type { IProject } from "@plane/types"; // ui // components import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; @@ -19,12 +17,11 @@ import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; // plane web imports import { CustomAutomationsRoot } from "@/plane-web/components/automations/root"; +import type { Route } from "./+types/page"; -const AutomationSettingsPage = observer(() => { +function AutomationSettingsPage({ params }: Route.ComponentProps) { // router - const { workspaceSlug: workspaceSlugParam, projectId: projectIdParam } = useParams(); - const workspaceSlug = workspaceSlugParam?.toString(); - const projectId = projectIdParam?.toString(); + const { workspaceSlug, projectId } = params; // store hooks const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { currentProjectDetails: projectDetails, updateProject } = useProject(); @@ -35,15 +32,17 @@ const AutomationSettingsPage = observer(() => { const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); const handleChange = async (formData: Partial) => { - if (!workspaceSlug || !projectId || !projectDetails) return; + if (!projectDetails) return; - await updateProject(workspaceSlug.toString(), projectId.toString(), formData).catch(() => { + try { + await updateProject(workspaceSlug, projectId, formData); + } catch { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", message: "Something went wrong. Please try again.", }); - }); + } }; // derived values @@ -67,6 +66,6 @@ const AutomationSettingsPage = observer(() => { ); -}); +} -export default AutomationSettingsPage; +export default observer(AutomationSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx index bc448aedb6..bbf1cd313a 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx @@ -1,7 +1,6 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // components import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; @@ -11,9 +10,10 @@ import { EstimateRoot } from "@/components/estimates"; import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; +import type { Route } from "./+types/page"; -const EstimatesSettingsPage = observer(() => { - const { workspaceSlug, projectId } = useParams(); +function EstimatesSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; // store const { currentProjectDetails } = useProject(); const { workspaceUserInfo, allowPermissions } = useUserPermissions(); @@ -22,8 +22,6 @@ const EstimatesSettingsPage = observer(() => { const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined; const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); - if (!workspaceSlug || !projectId) return <>; - if (workspaceUserInfo && !canPerformProjectAdminActions) { return ; } @@ -32,14 +30,10 @@ const EstimatesSettingsPage = observer(() => {
- +
); -}); +} -export default EstimatesSettingsPage; +export default observer(EstimatesSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx index 40d2bed66f..dbcb2c5aa7 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx @@ -1,19 +1,19 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // components import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { PageHead } from "@/components/core/page-title"; -import { ProjectFeaturesList } from "@/components/project/settings/features-list"; import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; +import { ProjectFeaturesList } from "@/plane-web/components/projects/settings/features-list"; +import type { Route } from "./+types/page"; -const FeaturesSettingsPage = observer(() => { - const { workspaceSlug, projectId } = useParams(); +function FeaturesSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; // store const { workspaceUserInfo, allowPermissions } = useUserPermissions(); @@ -22,8 +22,6 @@ const FeaturesSettingsPage = observer(() => { const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined; const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); - if (!workspaceSlug || !projectId) return null; - if (workspaceUserInfo && !canPerformProjectAdminActions) { return ; } @@ -33,13 +31,13 @@ const FeaturesSettingsPage = observer(() => {
); -}); +} -export default FeaturesSettingsPage; +export default observer(FeaturesSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx index 7579d6b5a2..acfff800d9 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx @@ -14,7 +14,7 @@ import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; -const LabelsSettingsPage = observer(() => { +function LabelsSettingsPage() { // store hooks const { currentProjectDetails } = useProject(); const { workspaceUserInfo, allowPermissions } = useUserPermissions(); @@ -54,6 +54,6 @@ const LabelsSettingsPage = observer(() => { ); -}); +} -export default LabelsSettingsPage; +export default observer(LabelsSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx index 545903ea2e..d12bc65a2f 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx @@ -1,7 +1,6 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; @@ -18,18 +17,17 @@ import { useUserPermissions } from "@/hooks/store/user"; // plane web imports import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list"; import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings"; +import type { Route } from "./+types/page"; -const MembersSettingsPage = observer(() => { +function MembersSettingsPage({ params }: Route.ComponentProps) { // router - const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams(); + const { workspaceSlug, projectId } = params; // plane hooks const { t } = useTranslation(); // store hooks const { currentProjectDetails } = useProject(); const { workspaceUserInfo, allowPermissions } = useUserPermissions(); // derived values - const projectId = routerProjectId?.toString(); - const workspaceSlug = routerWorkspaceSlug?.toString(); const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined; const isProjectMemberOrAdmin = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -51,6 +49,6 @@ const MembersSettingsPage = observer(() => { ); -}); +} -export default MembersSettingsPage; +export default observer(MembersSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx index 00899909ca..1d67c80afa 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import useSWR from "swr"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; @@ -18,41 +17,34 @@ import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; +import type { Route } from "./+types/page"; -const ProjectSettingsPage = observer(() => { +function ProjectSettingsPage({ params }: Route.ComponentProps) { // states const [selectProject, setSelectedProject] = useState(null); const [archiveProject, setArchiveProject] = useState(false); // router - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug, projectId } = params; // store hooks const { currentProjectDetails, fetchProjectDetails } = useProject(); const { allowPermissions } = useUserPermissions(); // api call to fetch project details // TODO: removed this API if not necessary - const { isLoading } = useSWR( - workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null, - workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()) : null - ); + const { isLoading } = useSWR(`PROJECT_DETAILS_${projectId}`, () => fetchProjectDetails(workspaceSlug, projectId)); // derived values - const isAdmin = allowPermissions( - [EUserPermissions.ADMIN], - EUserPermissionsLevel.PROJECT, - workspaceSlug.toString(), - projectId.toString() - ); + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId); const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined; return ( - {currentProjectDetails && workspaceSlug && projectId && ( + {currentProjectDetails && ( <> setArchiveProject(false)} archive @@ -66,11 +58,11 @@ const ProjectSettingsPage = observer(() => { )}
- {currentProjectDetails && workspaceSlug && projectId && !isLoading ? ( + {currentProjectDetails && !isLoading ? ( ) : ( @@ -92,6 +84,6 @@ const ProjectSettingsPage = observer(() => {
); -}); +} -export default ProjectSettingsPage; +export default observer(ProjectSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx index 338319e475..c85c643ddd 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx @@ -1,7 +1,6 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // components @@ -13,9 +12,10 @@ import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { SettingsHeading } from "@/components/settings/heading"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; +import type { Route } from "./+types/page"; -const StatesSettingsPage = observer(() => { - const { workspaceSlug, projectId } = useParams(); +function StatesSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; // store const { currentProjectDetails } = useProject(); const { workspaceUserInfo, allowPermissions } = useUserPermissions(); @@ -42,12 +42,10 @@ const StatesSettingsPage = observer(() => { title={t("project_settings.states.heading")} description={t("project_settings.states.description")} /> - {workspaceSlug && projectId && ( - - )} + ); -}); +} -export default StatesSettingsPage; +export default observer(StatesSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx index fd521b9cd9..2adbe99895 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx @@ -1,8 +1,9 @@ "use client"; -import { ReactNode, useEffect } from "react"; +import { useEffect } from "react"; import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; +import { usePathname } from "next/navigation"; +import { Outlet } from "react-router"; // components import { getProjectActivePath } from "@/components/settings/helper"; import { SettingsMobileNav } from "@/components/settings/mobile"; @@ -10,17 +11,13 @@ import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar"; import { useProject } from "@/hooks/store/use-project"; import { useAppRouter } from "@/hooks/use-app-router"; import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; +import type { Route } from "./+types/layout"; -type Props = { - children: ReactNode; -}; - -const ProjectSettingsLayout = observer((props: Props) => { - const { children } = props; +function ProjectSettingsLayout({ params }: Route.ComponentProps) { // router const router = useAppRouter(); const pathname = usePathname(); - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug, projectId } = params; const { joinedProjectIds } = useProject(); useEffect(() => { @@ -33,14 +30,16 @@ const ProjectSettingsLayout = observer((props: Props) => { return ( <> - +
{projectId && }
-
{children}
+
+ +
); -}); +} -export default ProjectSettingsLayout; +export default observer(ProjectSettingsLayout); diff --git a/apps/web/app/(all)/[workspaceSlug]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/layout.tsx index ed16556a9e..22f1be8a9e 100644 --- a/apps/web/app/(all)/[workspaceSlug]/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/layout.tsx @@ -1,12 +1,15 @@ "use client"; +import { Outlet } from "react-router"; import { AppRailProvider } from "@/hooks/context/app-rail-context"; import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/content-wrapper"; -export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { +export default function WorkspaceLayout() { return ( - {children} + + + ); } diff --git a/apps/web/app/(all)/accounts/forgot-password/layout.tsx b/apps/web/app/(all)/accounts/forgot-password/layout.tsx index 7ba8e8ded6..0e4b9b4beb 100644 --- a/apps/web/app/(all)/accounts/forgot-password/layout.tsx +++ b/apps/web/app/(all)/accounts/forgot-password/layout.tsx @@ -1,9 +1,8 @@ -import { Metadata } from "next"; +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; -export const metadata: Metadata = { - title: "Forgot Password - Plane", -}; - -export default function ForgotPasswordLayout({ children }: { children: React.ReactNode }) { - return children; +export default function ForgotPasswordLayout() { + return ; } + +export const meta: Route.MetaFunction = () => [{ title: "Forgot Password - Plane" }]; diff --git a/apps/web/app/(all)/accounts/forgot-password/page.tsx b/apps/web/app/(all)/accounts/forgot-password/page.tsx index c49e217486..7c121e25fc 100644 --- a/apps/web/app/(all)/accounts/forgot-password/page.tsx +++ b/apps/web/app/(all)/accounts/forgot-password/page.tsx @@ -10,15 +10,17 @@ import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; import DefaultLayout from "@/layouts/default-layout"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; -const ForgotPasswordPage = observer(() => ( - - -
- - -
-
-
-)); +function ForgotPasswordPage() { + return ( + + +
+ + +
+
+
+ ); +} -export default ForgotPasswordPage; +export default observer(ForgotPasswordPage); diff --git a/apps/web/app/(all)/accounts/reset-password/layout.tsx b/apps/web/app/(all)/accounts/reset-password/layout.tsx index dbc0a29b4b..30e798d44c 100644 --- a/apps/web/app/(all)/accounts/reset-password/layout.tsx +++ b/apps/web/app/(all)/accounts/reset-password/layout.tsx @@ -1,9 +1,8 @@ -import { Metadata } from "next"; +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; -export const metadata: Metadata = { - title: "Reset Password - Plane", -}; - -export default function ResetPasswordLayout({ children }: { children: React.ReactNode }) { - return children; +export default function ResetPasswordLayout() { + return ; } + +export const meta: Route.MetaFunction = () => [{ title: "Reset Password - Plane" }]; diff --git a/apps/web/app/(all)/accounts/set-password/layout.tsx b/apps/web/app/(all)/accounts/set-password/layout.tsx index dbd32e9e8c..290d153a95 100644 --- a/apps/web/app/(all)/accounts/set-password/layout.tsx +++ b/apps/web/app/(all)/accounts/set-password/layout.tsx @@ -1,9 +1,8 @@ -import { Metadata } from "next"; +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; -export const metadata: Metadata = { - title: "Set Password - Plane", -}; - -export default function SetPasswordLayout({ children }: { children: React.ReactNode }) { - return children; +export default function SetPasswordLayout() { + return ; } + +export const meta: Route.MetaFunction = () => [{ title: "Set Password - Plane" }]; diff --git a/apps/web/app/(all)/create-workspace/layout.tsx b/apps/web/app/(all)/create-workspace/layout.tsx index 32a220df7b..880b1f3947 100644 --- a/apps/web/app/(all)/create-workspace/layout.tsx +++ b/apps/web/app/(all)/create-workspace/layout.tsx @@ -1,9 +1,8 @@ -import { Metadata } from "next"; +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; -export const metadata: Metadata = { - title: "Create Workspace", -}; - -export default function CreateWorkspaceLayout({ children }: { children: React.ReactNode }) { - return children; +export default function CreateWorkspaceLayout() { + return ; } + +export const meta: Route.MetaFunction = () => [{ title: "Create Workspace" }]; diff --git a/apps/web/app/(all)/create-workspace/page.tsx b/apps/web/app/(all)/create-workspace/page.tsx index 84a9653d62..06708b44bf 100644 --- a/apps/web/app/(all)/create-workspace/page.tsx +++ b/apps/web/app/(all)/create-workspace/page.tsx @@ -8,7 +8,9 @@ import Link from "next/link"; import { useTranslation } from "@plane/i18n"; import { Button, getButtonStyling } from "@plane/propel/button"; import { PlaneLogo } from "@plane/propel/icons"; -import { IWorkspace } from "@plane/types"; +import type { IWorkspace } from "@plane/types"; +// assets +import WorkspaceCreationDisabled from "@/app/assets/workspace/workspace-creation-disabled.png?url"; // components import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form"; // hooks @@ -18,10 +20,8 @@ import { useAppRouter } from "@/hooks/use-app-router"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; // plane web helpers import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; -// images -import WorkspaceCreationDisabled from "@/public/workspace/workspace-creation-disabled.png"; -const CreateWorkspacePage = observer(() => { +function CreateWorkspacePage() { const { t } = useTranslation(); // router const router = useAppRouter(); @@ -103,6 +103,6 @@ const CreateWorkspacePage = observer(() => { ); -}); +} -export default CreateWorkspacePage; +export default observer(CreateWorkspacePage); diff --git a/apps/web/app/(all)/installations/[provider]/layout.tsx b/apps/web/app/(all)/installations/[provider]/layout.tsx index 51978de9e9..862fc54727 100644 --- a/apps/web/app/(all)/installations/[provider]/layout.tsx +++ b/apps/web/app/(all)/installations/[provider]/layout.tsx @@ -1,3 +1,8 @@ -export default function InstallationProviderLayout({ children }: { children: React.ReactNode }) { - return children; +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; + +export default function InstallationProviderLayout() { + return ; } + +export const meta: Route.MetaFunction = () => [{ title: "Installations" }]; diff --git a/apps/web/app/(all)/installations/[provider]/page.tsx b/apps/web/app/(all)/installations/[provider]/page.tsx index 03d224f585..ebb995a5d2 100644 --- a/apps/web/app/(all)/installations/[provider]/page.tsx +++ b/apps/web/app/(all)/installations/[provider]/page.tsx @@ -1,18 +1,19 @@ "use client"; -import React, { useEffect } from "react"; -import { useParams, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; +import { useSearchParams } from "next/navigation"; // ui import { LogoSpinner } from "@/components/common/logo-spinner"; // services import { AppInstallationService } from "@/services/app_installation.service"; +import type { Route } from "./+types/page"; // services const appInstallationService = new AppInstallationService(); -export default function AppPostInstallation() { +export default function AppPostInstallation({ params }: Route.ComponentProps) { // params - const { provider } = useParams(); + const { provider } = params; // query params const searchParams = useSearchParams(); const installation_id = searchParams.get("installation_id"); diff --git a/apps/web/app/(all)/invitations/layout.tsx b/apps/web/app/(all)/invitations/layout.tsx index 2d9a7e688e..cbda5f4c04 100644 --- a/apps/web/app/(all)/invitations/layout.tsx +++ b/apps/web/app/(all)/invitations/layout.tsx @@ -1,9 +1,8 @@ -import { Metadata } from "next"; +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; -export const metadata: Metadata = { - title: "Invitations", -}; - -export default function InvitationsLayout({ children }: { children: React.ReactNode }) { - return children; +export default function InvitationsLayout() { + return ; } + +export const meta: Route.MetaFunction = () => [{ title: "Invitations" }]; diff --git a/apps/web/app/(all)/invitations/page.tsx b/apps/web/app/(all)/invitations/page.tsx index 080322ba82..b67bc9b61e 100644 --- a/apps/web/app/(all)/invitations/page.tsx +++ b/apps/web/app/(all)/invitations/page.tsx @@ -15,6 +15,8 @@ import { PlaneLogo } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IWorkspaceMemberInvitation } from "@plane/types"; import { truncateText } from "@plane/utils"; +// assets +import emptyInvitation from "@/app/assets/empty-state/invitation.svg?url"; // components import { EmptyState } from "@/components/common/empty-state"; import { WorkspaceLogo } from "@/components/workspace/logo"; @@ -29,12 +31,10 @@ import { useAppRouter } from "@/hooks/use-app-router"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; // plane web services import { WorkspaceService } from "@/plane-web/services"; -// images -import emptyInvitation from "@/public/empty-state/invitation.svg"; const workspaceService = new WorkspaceService(); -const UserInvitationsPage = observer(() => { +function UserInvitationsPage() { // states const [invitationsRespond, setInvitationsRespond] = useState([]); const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); @@ -220,6 +220,6 @@ const UserInvitationsPage = observer(() => { ); -}); +} -export default UserInvitationsPage; +export default observer(UserInvitationsPage); diff --git a/apps/web/app/(all)/layout.preload.tsx b/apps/web/app/(all)/layout.preload.tsx index fb72b72a5c..e44c0e4002 100644 --- a/apps/web/app/(all)/layout.preload.tsx +++ b/apps/web/app/(all)/layout.preload.tsx @@ -1,28 +1,25 @@ "use client"; -import { useEffect } from "react"; -import ReactDOM from "react-dom"; - +// TODO: Check if we need this // https://nextjs.org/docs/app/api-reference/functions/generate-metadata#link-relpreload -export const usePreloadResources = () => { - useEffect(() => { - const preloadItem = (url: string) => { - ReactDOM.preload(url, { as: "fetch", crossOrigin: "use-credentials" }); - }; +// export const usePreloadResources = () => { +// useEffect(() => { +// const preloadItem = (url: string) => { +// ReactDOM.preload(url, { as: "fetch", crossOrigin: "use-credentials" }); +// }; - const urls = [ - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/instances/`, - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/`, - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/profile/`, - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/settings/`, - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`, - ]; +// const urls = [ +// `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/instances/`, +// `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/`, +// `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/profile/`, +// `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/settings/`, +// `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`, +// ]; - urls.forEach((url) => preloadItem(url)); - }, []); -}; +// urls.forEach((url) => preloadItem(url)); +// }, []); +// }; -export const PreloadResources = () => { - usePreloadResources(); - return null; -}; +export const PreloadResources = () => + // usePreloadResources(); + null; diff --git a/apps/web/app/(all)/layout.tsx b/apps/web/app/(all)/layout.tsx index 2fde651885..e87557cb7d 100644 --- a/apps/web/app/(all)/layout.tsx +++ b/apps/web/app/(all)/layout.tsx @@ -1,31 +1,23 @@ -import { Metadata, Viewport } from "next"; - +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; import { PreloadResources } from "./layout.preload"; +// types // styles -import "@/styles/command-pallette.css"; +import "@/styles/power-k.css"; import "@/styles/emoji.css"; -import "@plane/propel/styles/react-day-picker"; +import "@plane/propel/styles/react-day-picker.css"; -export const metadata: Metadata = { - robots: { - index: false, - follow: false, - }, -}; +export const meta: Route.MetaFunction = () => [ + { name: "robots", content: "noindex, nofollow" }, + { name: "viewport", content: "width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover" }, +]; -export const viewport: Viewport = { - minimumScale: 1, - initialScale: 1, - width: "device-width", - viewportFit: "cover", -}; - -export default function AppLayout({ children }: { children: React.ReactNode }) { +export default function AppLayout() { return ( <> - {children} + ); } diff --git a/apps/web/app/(all)/onboarding/layout.tsx b/apps/web/app/(all)/onboarding/layout.tsx index 492ebc4023..ac20091f29 100644 --- a/apps/web/app/(all)/onboarding/layout.tsx +++ b/apps/web/app/(all)/onboarding/layout.tsx @@ -1,9 +1,8 @@ -import { Metadata } from "next"; +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; -export const metadata: Metadata = { - title: "Onboarding", -}; - -export default function OnboardingLayout({ children }: { children: React.ReactNode }) { - return children; +export default function OnboardingLayout() { + return ; } + +export const meta: Route.MetaFunction = () => [{ title: "Onboarding" }]; diff --git a/apps/web/app/(all)/onboarding/page.tsx b/apps/web/app/(all)/onboarding/page.tsx index 14ef5881f2..bbcdc5585a 100644 --- a/apps/web/app/(all)/onboarding/page.tsx +++ b/apps/web/app/(all)/onboarding/page.tsx @@ -20,7 +20,7 @@ import { WorkspaceService } from "@/plane-web/services"; const workspaceService = new WorkspaceService(); -const OnboardingPage = observer(() => { +function OnboardingPage() { // store hooks const { data: user } = useUser(); const { fetchWorkspaces } = useWorkspace(); @@ -57,6 +57,6 @@ const OnboardingPage = observer(() => { ); -}); +} -export default OnboardingPage; +export default observer(OnboardingPage); diff --git a/apps/web/app/(all)/profile/activity/page.tsx b/apps/web/app/(all)/profile/activity/page.tsx index d978b9a046..39c4c479cb 100644 --- a/apps/web/app/(all)/profile/activity/page.tsx +++ b/apps/web/app/(all)/profile/activity/page.tsx @@ -2,30 +2,34 @@ import { useState } from "react"; import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; // plane imports import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; +// assets +import darkActivityAsset from "@/app/assets/empty-state/profile/activity-dark.webp?url"; +import lightActivityAsset from "@/app/assets/empty-state/profile/activity-light.webp?url"; // components import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; import { ProfileActivityListPage } from "@/components/profile/activity/profile-activity-list"; import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; -// hooks -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; const PER_PAGE = 100; -const ProfileActivityPage = observer(() => { +function ProfileActivityPage() { // states const [pageCount, setPageCount] = useState(1); const [totalPages, setTotalPages] = useState(0); const [resultsCount, setResultsCount] = useState(0); const [isEmpty, setIsEmpty] = useState(false); + // theme hook + const { resolvedTheme } = useTheme(); // plane hooks const { t } = useTranslation(); // derived values - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/profile/activity" }); + const resolvedPath = resolvedTheme === "light" ? lightActivityAsset : darkActivityAsset; const updateTotalPages = (count: number) => setTotalPages(count); @@ -76,6 +80,6 @@ const ProfileActivityPage = observer(() => { ); -}); +} -export default ProfileActivityPage; +export default observer(ProfileActivityPage); diff --git a/apps/web/app/(all)/profile/appearance/page.tsx b/apps/web/app/(all)/profile/appearance/page.tsx index 0fb15d9a97..e9c1ee96e1 100644 --- a/apps/web/app/(all)/profile/appearance/page.tsx +++ b/apps/web/app/(all)/profile/appearance/page.tsx @@ -4,10 +4,11 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; // plane imports -import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants"; +import type { I_THEME_OPTION } from "@plane/constants"; +import { THEME_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { setPromiseToast } from "@plane/propel/toast"; -import { IUserTheme } from "@plane/types"; +import type { IUserTheme } from "@plane/types"; // components import { applyTheme, unsetCustomCssVariables } from "@plane/utils"; import { LogoSpinner } from "@/components/common/logo-spinner"; @@ -19,7 +20,7 @@ import { ProfileSettingContentWrapper } from "@/components/profile/profile-setti // hooks import { useUserProfile } from "@/hooks/store/user"; -const ProfileAppearancePage = observer(() => { +function ProfileAppearancePage() { const { t } = useTranslation(); const { setTheme } = useTheme(); // states @@ -85,6 +86,6 @@ const ProfileAppearancePage = observer(() => { )} ); -}); +} -export default ProfileAppearancePage; +export default observer(ProfileAppearancePage); diff --git a/apps/web/app/(all)/profile/layout.tsx b/apps/web/app/(all)/profile/layout.tsx index d9a440fb43..e86f606810 100644 --- a/apps/web/app/(all)/profile/layout.tsx +++ b/apps/web/app/(all)/profile/layout.tsx @@ -1,28 +1,24 @@ "use client"; -import { ReactNode } from "react"; // components -import { CommandPalette } from "@/components/command-palette"; +import { Outlet } from "react-router"; // wrappers +import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; // layout import { ProfileLayoutSidebar } from "./sidebar"; -type Props = { - children: ReactNode; -}; - -export default function ProfileSettingsLayout(props: Props) { - const { children } = props; - +export default function ProfileSettingsLayout() { return ( <> - +
-
{children}
+
+ +
diff --git a/apps/web/app/(all)/profile/page.tsx b/apps/web/app/(all)/profile/page.tsx index 01ff111450..b89759fd49 100644 --- a/apps/web/app/(all)/profile/page.tsx +++ b/apps/web/app/(all)/profile/page.tsx @@ -11,7 +11,7 @@ import { ProfileSettingContentWrapper } from "@/components/profile/profile-setti // hooks import { useUser } from "@/hooks/store/user"; -const ProfileSettingsPage = observer(() => { +function ProfileSettingsPage() { const { t } = useTranslation(); // store hooks const { data: currentUser, userProfile } = useUser(); @@ -31,6 +31,6 @@ const ProfileSettingsPage = observer(() => { ); -}); +} -export default ProfileSettingsPage; +export default observer(ProfileSettingsPage); diff --git a/apps/web/app/(all)/profile/security/page.tsx b/apps/web/app/(all)/profile/security/page.tsx index 9bd3e72ee9..a0ee1f4726 100644 --- a/apps/web/app/(all)/profile/security/page.tsx +++ b/apps/web/app/(all)/profile/security/page.tsx @@ -16,7 +16,8 @@ import { PageHead } from "@/components/core/page-title"; import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; // helpers -import { authErrorHandler, type EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; +import { authErrorHandler } from "@/helpers/authentication.helper"; +import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; // hooks import { useUser } from "@/hooks/store/user"; // services @@ -42,7 +43,7 @@ const defaultShowPassword = { confirmPassword: false, }; -const SecurityPage = observer(() => { +const SecurityPage = () => { // store const { data: currentUser, changePassword } = useUser(); // states @@ -253,6 +254,6 @@ const SecurityPage = observer(() => { ); -}); +}; -export default SecurityPage; +export default observer(SecurityPage); diff --git a/apps/web/app/(all)/profile/sidebar.tsx b/apps/web/app/(all)/profile/sidebar.tsx index acf25bb180..4619b186fa 100644 --- a/apps/web/app/(all)/profile/sidebar.tsx +++ b/apps/web/app/(all)/profile/sidebar.tsx @@ -5,22 +5,12 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; // icons -import { - ChevronLeft, - LogOut, - MoveLeft, - Activity, - Bell, - CircleUser, - KeyRound, - Settings2, - CirclePlus, - Mails, -} from "lucide-react"; +import { LogOut, MoveLeft, Activity, Bell, CircleUser, KeyRound, Settings2, CirclePlus, Mails } from "lucide-react"; // plane imports import { PROFILE_ACTION_LINKS } from "@plane/constants"; import { useOutsideClickDetector } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; +import { ChevronLeftIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; import { cn, getFileURL } from "@plane/utils"; @@ -141,7 +131,7 @@ export const ProfileLayoutSidebar = observer(() => { }`} > - + {!sidebarCollapsed && (

{t("profile_settings")}

diff --git a/apps/web/app/(all)/sign-up/layout.tsx b/apps/web/app/(all)/sign-up/layout.tsx index 9e259b304a..daf5a0307b 100644 --- a/apps/web/app/(all)/sign-up/layout.tsx +++ b/apps/web/app/(all)/sign-up/layout.tsx @@ -1,13 +1,11 @@ -import { Metadata } from "next"; +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; -export const metadata: Metadata = { - title: "Sign up - Plane", - robots: { - index: true, - follow: false, - }, -}; +export const meta: Route.MetaFunction = () => [ + { title: "Sign up - Plane" }, + { name: "robots", content: "index, nofollow" }, +]; -export default function SignUpLayout({ children }: { children: React.ReactNode }) { - return children; +export default function SignUpLayout() { + return ; } diff --git a/apps/web/app/(all)/workspace-invitations/layout.tsx b/apps/web/app/(all)/workspace-invitations/layout.tsx index 8361dddfab..cab6733a62 100644 --- a/apps/web/app/(all)/workspace-invitations/layout.tsx +++ b/apps/web/app/(all)/workspace-invitations/layout.tsx @@ -1,9 +1,8 @@ -import { Metadata } from "next"; +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; -export const metadata: Metadata = { - title: "Workspace Invitations", -}; - -export default function WorkspaceInvitationsLayout({ children }: { children: React.ReactNode }) { - return children; +export default function WorkspaceInvitationsLayout() { + return ; } + +export const meta: Route.MetaFunction = () => [{ title: "Workspace Invitations" }]; diff --git a/apps/web/app/(all)/workspace-invitations/page.tsx b/apps/web/app/(all)/workspace-invitations/page.tsx index 6f9d78d56f..67d9b6c5a2 100644 --- a/apps/web/app/(all)/workspace-invitations/page.tsx +++ b/apps/web/app/(all)/workspace-invitations/page.tsx @@ -4,7 +4,8 @@ import React from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import useSWR from "swr"; -import { Boxes, Check, Share2, Star, User2, X } from "lucide-react"; +import { Boxes, Check, Share2, Star, User2 } from "lucide-react"; +import { CloseIcon } from "@plane/propel/icons"; // components import { LogoSpinner } from "@/components/common/logo-spinner"; import { EmptySpace, EmptySpaceItem } from "@/components/ui/empty-space"; @@ -23,7 +24,7 @@ import { WorkspaceService } from "@/plane-web/services"; // service initialization const workspaceService = new WorkspaceService(); -const WorkspaceInvitationPage = observer(() => { +function WorkspaceInvitationPage() { // router const router = useAppRouter(); // query params @@ -85,7 +86,7 @@ const WorkspaceInvitationPage = observer(() => { description="Your workspace is where you'll create projects, collaborate on your work items, and organize different streams of work in your Plane account." > - + ) ) : error || invitationDetail?.responded_at ? ( @@ -123,6 +124,6 @@ const WorkspaceInvitationPage = observer(() => { ); -}); +} -export default WorkspaceInvitationPage; +export default observer(WorkspaceInvitationPage); diff --git a/apps/web/app/(home)/layout.tsx b/apps/web/app/(home)/layout.tsx index af7645f3c7..a972f662ac 100644 --- a/apps/web/app/(home)/layout.tsx +++ b/apps/web/app/(home)/layout.tsx @@ -1,19 +1,12 @@ -import { Metadata, Viewport } from "next"; +import { Outlet } from "react-router"; +// types +import type { Route } from "./+types/layout"; -export const metadata: Metadata = { - robots: { - index: true, - follow: false, - }, -}; +export const meta: Route.MetaFunction = () => [ + { name: "robots", content: "index, nofollow" }, + { name: "viewport", content: "width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover" }, +]; -export const viewport: Viewport = { - minimumScale: 1, - initialScale: 1, - width: "device-width", - viewportFit: "cover", -}; - -export default function HomeLayout({ children }: { children: React.ReactNode }) { - return <>{children}; +export default function HomeLayout() { + return ; } diff --git a/apps/web/app/assets/404.svg b/apps/web/app/assets/404.svg new file mode 100644 index 0000000000..4c298417dc --- /dev/null +++ b/apps/web/app/assets/404.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/attachment/audio-icon.png b/apps/web/app/assets/attachment/audio-icon.png similarity index 100% rename from apps/web/public/attachment/audio-icon.png rename to apps/web/app/assets/attachment/audio-icon.png diff --git a/apps/web/public/attachment/css-icon.png b/apps/web/app/assets/attachment/css-icon.png similarity index 100% rename from apps/web/public/attachment/css-icon.png rename to apps/web/app/assets/attachment/css-icon.png diff --git a/apps/web/public/attachment/csv-icon.png b/apps/web/app/assets/attachment/csv-icon.png similarity index 100% rename from apps/web/public/attachment/csv-icon.png rename to apps/web/app/assets/attachment/csv-icon.png diff --git a/apps/web/public/attachment/default-icon.png b/apps/web/app/assets/attachment/default-icon.png similarity index 100% rename from apps/web/public/attachment/default-icon.png rename to apps/web/app/assets/attachment/default-icon.png diff --git a/apps/web/public/attachment/doc-icon.png b/apps/web/app/assets/attachment/doc-icon.png similarity index 100% rename from apps/web/public/attachment/doc-icon.png rename to apps/web/app/assets/attachment/doc-icon.png diff --git a/apps/web/public/attachment/excel-icon.png b/apps/web/app/assets/attachment/excel-icon.png similarity index 100% rename from apps/web/public/attachment/excel-icon.png rename to apps/web/app/assets/attachment/excel-icon.png diff --git a/apps/web/public/attachment/figma-icon.png b/apps/web/app/assets/attachment/figma-icon.png similarity index 100% rename from apps/web/public/attachment/figma-icon.png rename to apps/web/app/assets/attachment/figma-icon.png diff --git a/apps/web/public/attachment/html-icon.png b/apps/web/app/assets/attachment/html-icon.png similarity index 100% rename from apps/web/public/attachment/html-icon.png rename to apps/web/app/assets/attachment/html-icon.png diff --git a/apps/web/public/attachment/img-icon.png b/apps/web/app/assets/attachment/img-icon.png similarity index 100% rename from apps/web/public/attachment/img-icon.png rename to apps/web/app/assets/attachment/img-icon.png diff --git a/apps/web/public/attachment/jpg-icon.png b/apps/web/app/assets/attachment/jpg-icon.png similarity index 100% rename from apps/web/public/attachment/jpg-icon.png rename to apps/web/app/assets/attachment/jpg-icon.png diff --git a/apps/web/public/attachment/js-icon.png b/apps/web/app/assets/attachment/js-icon.png similarity index 100% rename from apps/web/public/attachment/js-icon.png rename to apps/web/app/assets/attachment/js-icon.png diff --git a/apps/web/public/attachment/pdf-icon.png b/apps/web/app/assets/attachment/pdf-icon.png similarity index 100% rename from apps/web/public/attachment/pdf-icon.png rename to apps/web/app/assets/attachment/pdf-icon.png diff --git a/apps/web/public/attachment/png-icon.png b/apps/web/app/assets/attachment/png-icon.png similarity index 100% rename from apps/web/public/attachment/png-icon.png rename to apps/web/app/assets/attachment/png-icon.png diff --git a/apps/web/public/attachment/rar-icon.png b/apps/web/app/assets/attachment/rar-icon.png similarity index 100% rename from apps/web/public/attachment/rar-icon.png rename to apps/web/app/assets/attachment/rar-icon.png diff --git a/apps/web/public/attachment/svg-icon.png b/apps/web/app/assets/attachment/svg-icon.png similarity index 100% rename from apps/web/public/attachment/svg-icon.png rename to apps/web/app/assets/attachment/svg-icon.png diff --git a/apps/web/public/attachment/txt-icon.png b/apps/web/app/assets/attachment/txt-icon.png similarity index 100% rename from apps/web/public/attachment/txt-icon.png rename to apps/web/app/assets/attachment/txt-icon.png diff --git a/apps/web/public/attachment/video-icon.png b/apps/web/app/assets/attachment/video-icon.png similarity index 100% rename from apps/web/public/attachment/video-icon.png rename to apps/web/app/assets/attachment/video-icon.png diff --git a/apps/web/public/attachment/zip-icon.png b/apps/web/app/assets/attachment/zip-icon.png similarity index 100% rename from apps/web/public/attachment/zip-icon.png rename to apps/web/app/assets/attachment/zip-icon.png diff --git a/apps/web/public/auth/access-denied.svg b/apps/web/app/assets/auth/access-denied.svg similarity index 100% rename from apps/web/public/auth/access-denied.svg rename to apps/web/app/assets/auth/access-denied.svg diff --git a/apps/space/public/auth/background-pattern-dark.svg b/apps/web/app/assets/auth/background-pattern-dark.svg similarity index 100% rename from apps/space/public/auth/background-pattern-dark.svg rename to apps/web/app/assets/auth/background-pattern-dark.svg diff --git a/apps/space/public/auth/background-pattern.svg b/apps/web/app/assets/auth/background-pattern.svg similarity index 100% rename from apps/space/public/auth/background-pattern.svg rename to apps/web/app/assets/auth/background-pattern.svg diff --git a/apps/web/public/auth/project-not-authorized.svg b/apps/web/app/assets/auth/project-not-authorized.svg similarity index 100% rename from apps/web/public/auth/project-not-authorized.svg rename to apps/web/app/assets/auth/project-not-authorized.svg diff --git a/apps/web/public/auth/unauthorized.svg b/apps/web/app/assets/auth/unauthorized.svg similarity index 100% rename from apps/web/public/auth/unauthorized.svg rename to apps/web/app/assets/auth/unauthorized.svg diff --git a/apps/web/public/auth/workspace-not-authorized.svg b/apps/web/app/assets/auth/workspace-not-authorized.svg similarity index 100% rename from apps/web/public/auth/workspace-not-authorized.svg rename to apps/web/app/assets/auth/workspace-not-authorized.svg diff --git a/apps/web/public/emoji/project-emoji.svg b/apps/web/app/assets/emoji/project-emoji.svg similarity index 100% rename from apps/web/public/emoji/project-emoji.svg rename to apps/web/app/assets/emoji/project-emoji.svg diff --git a/apps/web/public/empty-state/active-cycle/assignee-dark.webp b/apps/web/app/assets/empty-state/active-cycle/assignee-dark.webp similarity index 100% rename from apps/web/public/empty-state/active-cycle/assignee-dark.webp rename to apps/web/app/assets/empty-state/active-cycle/assignee-dark.webp diff --git a/apps/web/public/empty-state/active-cycle/assignee-light.webp b/apps/web/app/assets/empty-state/active-cycle/assignee-light.webp similarity index 100% rename from apps/web/public/empty-state/active-cycle/assignee-light.webp rename to apps/web/app/assets/empty-state/active-cycle/assignee-light.webp diff --git a/apps/web/public/empty-state/active-cycle/chart-dark.webp b/apps/web/app/assets/empty-state/active-cycle/chart-dark.webp similarity index 100% rename from apps/web/public/empty-state/active-cycle/chart-dark.webp rename to apps/web/app/assets/empty-state/active-cycle/chart-dark.webp diff --git a/apps/web/public/empty-state/active-cycle/chart-light.webp b/apps/web/app/assets/empty-state/active-cycle/chart-light.webp similarity index 100% rename from apps/web/public/empty-state/active-cycle/chart-light.webp rename to apps/web/app/assets/empty-state/active-cycle/chart-light.webp diff --git a/apps/web/public/empty-state/active-cycle/cycle-dark.webp b/apps/web/app/assets/empty-state/active-cycle/cycle-dark.webp similarity index 100% rename from apps/web/public/empty-state/active-cycle/cycle-dark.webp rename to apps/web/app/assets/empty-state/active-cycle/cycle-dark.webp diff --git a/apps/web/public/empty-state/active-cycle/cycle-light.webp b/apps/web/app/assets/empty-state/active-cycle/cycle-light.webp similarity index 100% rename from apps/web/public/empty-state/active-cycle/cycle-light.webp rename to apps/web/app/assets/empty-state/active-cycle/cycle-light.webp diff --git a/apps/web/public/empty-state/active-cycle/label-dark.webp b/apps/web/app/assets/empty-state/active-cycle/label-dark.webp similarity index 100% rename from apps/web/public/empty-state/active-cycle/label-dark.webp rename to apps/web/app/assets/empty-state/active-cycle/label-dark.webp diff --git a/apps/web/public/empty-state/active-cycle/label-light.webp b/apps/web/app/assets/empty-state/active-cycle/label-light.webp similarity index 100% rename from apps/web/public/empty-state/active-cycle/label-light.webp rename to apps/web/app/assets/empty-state/active-cycle/label-light.webp diff --git a/apps/web/public/empty-state/active-cycle/priority-dark.webp b/apps/web/app/assets/empty-state/active-cycle/priority-dark.webp similarity index 100% rename from apps/web/public/empty-state/active-cycle/priority-dark.webp rename to apps/web/app/assets/empty-state/active-cycle/priority-dark.webp diff --git a/apps/web/public/empty-state/active-cycle/priority-light.webp b/apps/web/app/assets/empty-state/active-cycle/priority-light.webp similarity index 100% rename from apps/web/public/empty-state/active-cycle/priority-light.webp rename to apps/web/app/assets/empty-state/active-cycle/priority-light.webp diff --git a/apps/web/public/empty-state/active-cycle/progress-dark.webp b/apps/web/app/assets/empty-state/active-cycle/progress-dark.webp similarity index 100% rename from apps/web/public/empty-state/active-cycle/progress-dark.webp rename to apps/web/app/assets/empty-state/active-cycle/progress-dark.webp diff --git a/apps/web/public/empty-state/active-cycle/progress-light.webp b/apps/web/app/assets/empty-state/active-cycle/progress-light.webp similarity index 100% rename from apps/web/public/empty-state/active-cycle/progress-light.webp rename to apps/web/app/assets/empty-state/active-cycle/progress-light.webp diff --git a/apps/web/public/empty-state/all-issues/all-issues-dark.webp b/apps/web/app/assets/empty-state/all-issues/all-issues-dark.webp similarity index 100% rename from apps/web/public/empty-state/all-issues/all-issues-dark.webp rename to apps/web/app/assets/empty-state/all-issues/all-issues-dark.webp diff --git a/apps/web/public/empty-state/all-issues/all-issues-light.webp b/apps/web/app/assets/empty-state/all-issues/all-issues-light.webp similarity index 100% rename from apps/web/public/empty-state/all-issues/all-issues-light.webp rename to apps/web/app/assets/empty-state/all-issues/all-issues-light.webp diff --git a/apps/web/public/empty-state/all-issues/assigned-dark.webp b/apps/web/app/assets/empty-state/all-issues/assigned-dark.webp similarity index 100% rename from apps/web/public/empty-state/all-issues/assigned-dark.webp rename to apps/web/app/assets/empty-state/all-issues/assigned-dark.webp diff --git a/apps/web/public/empty-state/all-issues/assigned-light.webp b/apps/web/app/assets/empty-state/all-issues/assigned-light.webp similarity index 100% rename from apps/web/public/empty-state/all-issues/assigned-light.webp rename to apps/web/app/assets/empty-state/all-issues/assigned-light.webp diff --git a/apps/web/public/empty-state/all-issues/created-dark.webp b/apps/web/app/assets/empty-state/all-issues/created-dark.webp similarity index 100% rename from apps/web/public/empty-state/all-issues/created-dark.webp rename to apps/web/app/assets/empty-state/all-issues/created-dark.webp diff --git a/apps/web/public/empty-state/all-issues/created-light.webp b/apps/web/app/assets/empty-state/all-issues/created-light.webp similarity index 100% rename from apps/web/public/empty-state/all-issues/created-light.webp rename to apps/web/app/assets/empty-state/all-issues/created-light.webp diff --git a/apps/web/public/empty-state/all-issues/custom-view-dark.webp b/apps/web/app/assets/empty-state/all-issues/custom-view-dark.webp similarity index 100% rename from apps/web/public/empty-state/all-issues/custom-view-dark.webp rename to apps/web/app/assets/empty-state/all-issues/custom-view-dark.webp diff --git a/apps/web/public/empty-state/all-issues/custom-view-light.webp b/apps/web/app/assets/empty-state/all-issues/custom-view-light.webp similarity index 100% rename from apps/web/public/empty-state/all-issues/custom-view-light.webp rename to apps/web/app/assets/empty-state/all-issues/custom-view-light.webp diff --git a/apps/web/public/empty-state/all-issues/no-project-dark.webp b/apps/web/app/assets/empty-state/all-issues/no-project-dark.webp similarity index 100% rename from apps/web/public/empty-state/all-issues/no-project-dark.webp rename to apps/web/app/assets/empty-state/all-issues/no-project-dark.webp diff --git a/apps/web/public/empty-state/all-issues/no-project-light.webp b/apps/web/app/assets/empty-state/all-issues/no-project-light.webp similarity index 100% rename from apps/web/public/empty-state/all-issues/no-project-light.webp rename to apps/web/app/assets/empty-state/all-issues/no-project-light.webp diff --git a/apps/web/public/empty-state/all-issues/subscribed-dark.webp b/apps/web/app/assets/empty-state/all-issues/subscribed-dark.webp similarity index 100% rename from apps/web/public/empty-state/all-issues/subscribed-dark.webp rename to apps/web/app/assets/empty-state/all-issues/subscribed-dark.webp diff --git a/apps/web/public/empty-state/all-issues/subscribed-light.webp b/apps/web/app/assets/empty-state/all-issues/subscribed-light.webp similarity index 100% rename from apps/web/public/empty-state/all-issues/subscribed-light.webp rename to apps/web/app/assets/empty-state/all-issues/subscribed-light.webp diff --git a/apps/web/public/empty-state/analytics/empty-chart-area-dark.webp b/apps/web/app/assets/empty-state/analytics/empty-chart-area-dark.webp similarity index 100% rename from apps/web/public/empty-state/analytics/empty-chart-area-dark.webp rename to apps/web/app/assets/empty-state/analytics/empty-chart-area-dark.webp diff --git a/apps/web/public/empty-state/analytics/empty-chart-area-light.webp b/apps/web/app/assets/empty-state/analytics/empty-chart-area-light.webp similarity index 100% rename from apps/web/public/empty-state/analytics/empty-chart-area-light.webp rename to apps/web/app/assets/empty-state/analytics/empty-chart-area-light.webp diff --git a/apps/web/public/empty-state/analytics/empty-chart-bar-dark.webp b/apps/web/app/assets/empty-state/analytics/empty-chart-bar-dark.webp similarity index 100% rename from apps/web/public/empty-state/analytics/empty-chart-bar-dark.webp rename to apps/web/app/assets/empty-state/analytics/empty-chart-bar-dark.webp diff --git a/apps/web/public/empty-state/analytics/empty-chart-bar-light.webp b/apps/web/app/assets/empty-state/analytics/empty-chart-bar-light.webp similarity index 100% rename from apps/web/public/empty-state/analytics/empty-chart-bar-light.webp rename to apps/web/app/assets/empty-state/analytics/empty-chart-bar-light.webp diff --git a/apps/web/public/empty-state/analytics/empty-chart-radar-dark.webp b/apps/web/app/assets/empty-state/analytics/empty-chart-radar-dark.webp similarity index 100% rename from apps/web/public/empty-state/analytics/empty-chart-radar-dark.webp rename to apps/web/app/assets/empty-state/analytics/empty-chart-radar-dark.webp diff --git a/apps/web/public/empty-state/analytics/empty-chart-radar-light.webp b/apps/web/app/assets/empty-state/analytics/empty-chart-radar-light.webp similarity index 100% rename from apps/web/public/empty-state/analytics/empty-chart-radar-light.webp rename to apps/web/app/assets/empty-state/analytics/empty-chart-radar-light.webp diff --git a/apps/web/public/empty-state/analytics/empty-grid-background-dark.webp b/apps/web/app/assets/empty-state/analytics/empty-grid-background-dark.webp similarity index 100% rename from apps/web/public/empty-state/analytics/empty-grid-background-dark.webp rename to apps/web/app/assets/empty-state/analytics/empty-grid-background-dark.webp diff --git a/apps/web/public/empty-state/analytics/empty-grid-background-light.webp b/apps/web/app/assets/empty-state/analytics/empty-grid-background-light.webp similarity index 100% rename from apps/web/public/empty-state/analytics/empty-grid-background-light.webp rename to apps/web/app/assets/empty-state/analytics/empty-grid-background-light.webp diff --git a/apps/web/public/empty-state/analytics/empty-table-dark.webp b/apps/web/app/assets/empty-state/analytics/empty-table-dark.webp similarity index 100% rename from apps/web/public/empty-state/analytics/empty-table-dark.webp rename to apps/web/app/assets/empty-state/analytics/empty-table-dark.webp diff --git a/apps/web/public/empty-state/analytics/empty-table-light.webp b/apps/web/app/assets/empty-state/analytics/empty-table-light.webp similarity index 100% rename from apps/web/public/empty-state/analytics/empty-table-light.webp rename to apps/web/app/assets/empty-state/analytics/empty-table-light.webp diff --git a/apps/web/public/empty-state/api-token.svg b/apps/web/app/assets/empty-state/api-token.svg similarity index 100% rename from apps/web/public/empty-state/api-token.svg rename to apps/web/app/assets/empty-state/api-token.svg diff --git a/apps/web/public/empty-state/archived/empty-cycles-dark.webp b/apps/web/app/assets/empty-state/archived/empty-cycles-dark.webp similarity index 100% rename from apps/web/public/empty-state/archived/empty-cycles-dark.webp rename to apps/web/app/assets/empty-state/archived/empty-cycles-dark.webp diff --git a/apps/web/public/empty-state/archived/empty-cycles-light.webp b/apps/web/app/assets/empty-state/archived/empty-cycles-light.webp similarity index 100% rename from apps/web/public/empty-state/archived/empty-cycles-light.webp rename to apps/web/app/assets/empty-state/archived/empty-cycles-light.webp diff --git a/apps/web/public/empty-state/archived/empty-issues-dark.webp b/apps/web/app/assets/empty-state/archived/empty-issues-dark.webp similarity index 100% rename from apps/web/public/empty-state/archived/empty-issues-dark.webp rename to apps/web/app/assets/empty-state/archived/empty-issues-dark.webp diff --git a/apps/web/public/empty-state/archived/empty-issues-light.webp b/apps/web/app/assets/empty-state/archived/empty-issues-light.webp similarity index 100% rename from apps/web/public/empty-state/archived/empty-issues-light.webp rename to apps/web/app/assets/empty-state/archived/empty-issues-light.webp diff --git a/apps/web/public/empty-state/archived/empty-modules-dark.webp b/apps/web/app/assets/empty-state/archived/empty-modules-dark.webp similarity index 100% rename from apps/web/public/empty-state/archived/empty-modules-dark.webp rename to apps/web/app/assets/empty-state/archived/empty-modules-dark.webp diff --git a/apps/web/public/empty-state/archived/empty-modules-light.webp b/apps/web/app/assets/empty-state/archived/empty-modules-light.webp similarity index 100% rename from apps/web/public/empty-state/archived/empty-modules-light.webp rename to apps/web/app/assets/empty-state/archived/empty-modules-light.webp diff --git a/apps/web/public/empty-state/cycle-issues/calendar-dark-resp.webp b/apps/web/app/assets/empty-state/cycle-issues/calendar-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/calendar-dark-resp.webp rename to apps/web/app/assets/empty-state/cycle-issues/calendar-dark-resp.webp diff --git a/apps/web/public/empty-state/cycle-issues/calendar-dark.webp b/apps/web/app/assets/empty-state/cycle-issues/calendar-dark.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/calendar-dark.webp rename to apps/web/app/assets/empty-state/cycle-issues/calendar-dark.webp diff --git a/apps/web/public/empty-state/cycle-issues/calendar-light-resp.webp b/apps/web/app/assets/empty-state/cycle-issues/calendar-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/calendar-light-resp.webp rename to apps/web/app/assets/empty-state/cycle-issues/calendar-light-resp.webp diff --git a/apps/web/public/empty-state/cycle-issues/calendar-light.webp b/apps/web/app/assets/empty-state/cycle-issues/calendar-light.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/calendar-light.webp rename to apps/web/app/assets/empty-state/cycle-issues/calendar-light.webp diff --git a/apps/web/public/empty-state/cycle-issues/gantt_chart-dark-resp.webp b/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/gantt_chart-dark-resp.webp rename to apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark-resp.webp diff --git a/apps/web/public/empty-state/cycle-issues/gantt_chart-dark.webp b/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/gantt_chart-dark.webp rename to apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark.webp diff --git a/apps/web/public/empty-state/cycle-issues/gantt_chart-light-resp.webp b/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/gantt_chart-light-resp.webp rename to apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light-resp.webp diff --git a/apps/web/public/empty-state/cycle-issues/gantt_chart-light.webp b/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/gantt_chart-light.webp rename to apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light.webp diff --git a/apps/web/public/empty-state/cycle-issues/kanban-dark-resp.webp b/apps/web/app/assets/empty-state/cycle-issues/kanban-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/kanban-dark-resp.webp rename to apps/web/app/assets/empty-state/cycle-issues/kanban-dark-resp.webp diff --git a/apps/web/public/empty-state/cycle-issues/kanban-dark.webp b/apps/web/app/assets/empty-state/cycle-issues/kanban-dark.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/kanban-dark.webp rename to apps/web/app/assets/empty-state/cycle-issues/kanban-dark.webp diff --git a/apps/web/public/empty-state/cycle-issues/kanban-light-resp.webp b/apps/web/app/assets/empty-state/cycle-issues/kanban-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/kanban-light-resp.webp rename to apps/web/app/assets/empty-state/cycle-issues/kanban-light-resp.webp diff --git a/apps/web/public/empty-state/cycle-issues/kanban-light.webp b/apps/web/app/assets/empty-state/cycle-issues/kanban-light.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/kanban-light.webp rename to apps/web/app/assets/empty-state/cycle-issues/kanban-light.webp diff --git a/apps/web/public/empty-state/cycle-issues/list-dark-resp.webp b/apps/web/app/assets/empty-state/cycle-issues/list-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/list-dark-resp.webp rename to apps/web/app/assets/empty-state/cycle-issues/list-dark-resp.webp diff --git a/apps/web/public/empty-state/cycle-issues/list-dark.webp b/apps/web/app/assets/empty-state/cycle-issues/list-dark.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/list-dark.webp rename to apps/web/app/assets/empty-state/cycle-issues/list-dark.webp diff --git a/apps/web/public/empty-state/cycle-issues/list-light-resp.webp b/apps/web/app/assets/empty-state/cycle-issues/list-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/list-light-resp.webp rename to apps/web/app/assets/empty-state/cycle-issues/list-light-resp.webp diff --git a/apps/web/public/empty-state/cycle-issues/list-light.webp b/apps/web/app/assets/empty-state/cycle-issues/list-light.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/list-light.webp rename to apps/web/app/assets/empty-state/cycle-issues/list-light.webp diff --git a/apps/web/public/empty-state/cycle-issues/spreadsheet-dark-resp.webp b/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/spreadsheet-dark-resp.webp rename to apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark-resp.webp diff --git a/apps/web/public/empty-state/cycle-issues/spreadsheet-dark.webp b/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/spreadsheet-dark.webp rename to apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark.webp diff --git a/apps/web/public/empty-state/cycle-issues/spreadsheet-light-resp.webp b/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/spreadsheet-light-resp.webp rename to apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light-resp.webp diff --git a/apps/web/public/empty-state/cycle-issues/spreadsheet-light.webp b/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light.webp similarity index 100% rename from apps/web/public/empty-state/cycle-issues/spreadsheet-light.webp rename to apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light.webp diff --git a/apps/web/public/empty-state/cycle.svg b/apps/web/app/assets/empty-state/cycle.svg similarity index 100% rename from apps/web/public/empty-state/cycle.svg rename to apps/web/app/assets/empty-state/cycle.svg diff --git a/apps/web/public/empty-state/cycle/active-dark.webp b/apps/web/app/assets/empty-state/cycle/active-dark.webp similarity index 100% rename from apps/web/public/empty-state/cycle/active-dark.webp rename to apps/web/app/assets/empty-state/cycle/active-dark.webp diff --git a/apps/web/public/empty-state/cycle/active-light.webp b/apps/web/app/assets/empty-state/cycle/active-light.webp similarity index 100% rename from apps/web/public/empty-state/cycle/active-light.webp rename to apps/web/app/assets/empty-state/cycle/active-light.webp diff --git a/apps/web/public/empty-state/cycle/all-filters.svg b/apps/web/app/assets/empty-state/cycle/all-filters.svg similarity index 100% rename from apps/web/public/empty-state/cycle/all-filters.svg rename to apps/web/app/assets/empty-state/cycle/all-filters.svg diff --git a/apps/web/public/empty-state/cycle/completed-dark.webp b/apps/web/app/assets/empty-state/cycle/completed-dark.webp similarity index 100% rename from apps/web/public/empty-state/cycle/completed-dark.webp rename to apps/web/app/assets/empty-state/cycle/completed-dark.webp diff --git a/apps/web/public/empty-state/cycle/completed-light.webp b/apps/web/app/assets/empty-state/cycle/completed-light.webp similarity index 100% rename from apps/web/public/empty-state/cycle/completed-light.webp rename to apps/web/app/assets/empty-state/cycle/completed-light.webp diff --git a/apps/web/public/empty-state/cycle/completed-no-issues-dark.webp b/apps/web/app/assets/empty-state/cycle/completed-no-issues-dark.webp similarity index 100% rename from apps/web/public/empty-state/cycle/completed-no-issues-dark.webp rename to apps/web/app/assets/empty-state/cycle/completed-no-issues-dark.webp diff --git a/apps/web/public/empty-state/cycle/completed-no-issues-light.webp b/apps/web/app/assets/empty-state/cycle/completed-no-issues-light.webp similarity index 100% rename from apps/web/public/empty-state/cycle/completed-no-issues-light.webp rename to apps/web/app/assets/empty-state/cycle/completed-no-issues-light.webp diff --git a/apps/web/public/empty-state/cycle/draft-dark.webp b/apps/web/app/assets/empty-state/cycle/draft-dark.webp similarity index 100% rename from apps/web/public/empty-state/cycle/draft-dark.webp rename to apps/web/app/assets/empty-state/cycle/draft-dark.webp diff --git a/apps/web/public/empty-state/cycle/draft-light.webp b/apps/web/app/assets/empty-state/cycle/draft-light.webp similarity index 100% rename from apps/web/public/empty-state/cycle/draft-light.webp rename to apps/web/app/assets/empty-state/cycle/draft-light.webp diff --git a/apps/web/public/empty-state/cycle/name-filter.svg b/apps/web/app/assets/empty-state/cycle/name-filter.svg similarity index 100% rename from apps/web/public/empty-state/cycle/name-filter.svg rename to apps/web/app/assets/empty-state/cycle/name-filter.svg diff --git a/apps/web/public/empty-state/cycle/upcoming-dark.webp b/apps/web/app/assets/empty-state/cycle/upcoming-dark.webp similarity index 100% rename from apps/web/public/empty-state/cycle/upcoming-dark.webp rename to apps/web/app/assets/empty-state/cycle/upcoming-dark.webp diff --git a/apps/web/public/empty-state/cycle/upcoming-light.webp b/apps/web/app/assets/empty-state/cycle/upcoming-light.webp similarity index 100% rename from apps/web/public/empty-state/cycle/upcoming-light.webp rename to apps/web/app/assets/empty-state/cycle/upcoming-light.webp diff --git a/apps/web/public/empty-state/dashboard/dark/completed-issues.svg b/apps/web/app/assets/empty-state/dashboard/dark/completed-issues.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/dark/completed-issues.svg rename to apps/web/app/assets/empty-state/dashboard/dark/completed-issues.svg diff --git a/apps/web/public/empty-state/dashboard/dark/issues-by-priority.svg b/apps/web/app/assets/empty-state/dashboard/dark/issues-by-priority.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/dark/issues-by-priority.svg rename to apps/web/app/assets/empty-state/dashboard/dark/issues-by-priority.svg diff --git a/apps/web/public/empty-state/dashboard/dark/issues-by-state-group.svg b/apps/web/app/assets/empty-state/dashboard/dark/issues-by-state-group.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/dark/issues-by-state-group.svg rename to apps/web/app/assets/empty-state/dashboard/dark/issues-by-state-group.svg diff --git a/apps/web/public/empty-state/dashboard/dark/overdue-issues.svg b/apps/web/app/assets/empty-state/dashboard/dark/overdue-issues.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/dark/overdue-issues.svg rename to apps/web/app/assets/empty-state/dashboard/dark/overdue-issues.svg diff --git a/apps/web/public/empty-state/dashboard/dark/recent-activity.svg b/apps/web/app/assets/empty-state/dashboard/dark/recent-activity.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/dark/recent-activity.svg rename to apps/web/app/assets/empty-state/dashboard/dark/recent-activity.svg diff --git a/apps/web/public/empty-state/dashboard/dark/recent-collaborators-1.svg b/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-1.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/dark/recent-collaborators-1.svg rename to apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-1.svg diff --git a/apps/web/public/empty-state/dashboard/dark/recent-collaborators-2.svg b/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-2.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/dark/recent-collaborators-2.svg rename to apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-2.svg diff --git a/apps/web/public/empty-state/dashboard/dark/recent-collaborators-3.svg b/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-3.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/dark/recent-collaborators-3.svg rename to apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-3.svg diff --git a/apps/web/public/empty-state/dashboard/dark/upcoming-issues.svg b/apps/web/app/assets/empty-state/dashboard/dark/upcoming-issues.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/dark/upcoming-issues.svg rename to apps/web/app/assets/empty-state/dashboard/dark/upcoming-issues.svg diff --git a/apps/web/public/empty-state/dashboard/light/completed-issues.svg b/apps/web/app/assets/empty-state/dashboard/light/completed-issues.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/light/completed-issues.svg rename to apps/web/app/assets/empty-state/dashboard/light/completed-issues.svg diff --git a/apps/web/public/empty-state/dashboard/light/issues-by-priority.svg b/apps/web/app/assets/empty-state/dashboard/light/issues-by-priority.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/light/issues-by-priority.svg rename to apps/web/app/assets/empty-state/dashboard/light/issues-by-priority.svg diff --git a/apps/web/public/empty-state/dashboard/light/issues-by-state-group.svg b/apps/web/app/assets/empty-state/dashboard/light/issues-by-state-group.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/light/issues-by-state-group.svg rename to apps/web/app/assets/empty-state/dashboard/light/issues-by-state-group.svg diff --git a/apps/web/public/empty-state/dashboard/light/overdue-issues.svg b/apps/web/app/assets/empty-state/dashboard/light/overdue-issues.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/light/overdue-issues.svg rename to apps/web/app/assets/empty-state/dashboard/light/overdue-issues.svg diff --git a/apps/web/public/empty-state/dashboard/light/recent-activity.svg b/apps/web/app/assets/empty-state/dashboard/light/recent-activity.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/light/recent-activity.svg rename to apps/web/app/assets/empty-state/dashboard/light/recent-activity.svg diff --git a/apps/web/public/empty-state/dashboard/light/recent-collaborators-1.svg b/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-1.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/light/recent-collaborators-1.svg rename to apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-1.svg diff --git a/apps/web/public/empty-state/dashboard/light/recent-collaborators-2.svg b/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-2.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/light/recent-collaborators-2.svg rename to apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-2.svg diff --git a/apps/web/public/empty-state/dashboard/light/recent-collaborators-3.svg b/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-3.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/light/recent-collaborators-3.svg rename to apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-3.svg diff --git a/apps/web/public/empty-state/dashboard/light/upcoming-issues.svg b/apps/web/app/assets/empty-state/dashboard/light/upcoming-issues.svg similarity index 100% rename from apps/web/public/empty-state/dashboard/light/upcoming-issues.svg rename to apps/web/app/assets/empty-state/dashboard/light/upcoming-issues.svg diff --git a/apps/web/public/empty-state/dashboard/widgets-dark.webp b/apps/web/app/assets/empty-state/dashboard/widgets-dark.webp similarity index 100% rename from apps/web/public/empty-state/dashboard/widgets-dark.webp rename to apps/web/app/assets/empty-state/dashboard/widgets-dark.webp diff --git a/apps/web/public/empty-state/dashboard/widgets-light.webp b/apps/web/app/assets/empty-state/dashboard/widgets-light.webp similarity index 100% rename from apps/web/public/empty-state/dashboard/widgets-light.webp rename to apps/web/app/assets/empty-state/dashboard/widgets-light.webp diff --git a/apps/web/public/empty-state/dashboard_empty_project.webp b/apps/web/app/assets/empty-state/dashboard_empty_project.webp similarity index 100% rename from apps/web/public/empty-state/dashboard_empty_project.webp rename to apps/web/app/assets/empty-state/dashboard_empty_project.webp diff --git a/apps/web/public/empty-state/disabled-feature/cycles-dark.webp b/apps/web/app/assets/empty-state/disabled-feature/cycles-dark.webp similarity index 100% rename from apps/web/public/empty-state/disabled-feature/cycles-dark.webp rename to apps/web/app/assets/empty-state/disabled-feature/cycles-dark.webp diff --git a/apps/web/public/empty-state/disabled-feature/cycles-light.webp b/apps/web/app/assets/empty-state/disabled-feature/cycles-light.webp similarity index 100% rename from apps/web/public/empty-state/disabled-feature/cycles-light.webp rename to apps/web/app/assets/empty-state/disabled-feature/cycles-light.webp diff --git a/apps/web/public/empty-state/disabled-feature/intake-dark.webp b/apps/web/app/assets/empty-state/disabled-feature/intake-dark.webp similarity index 100% rename from apps/web/public/empty-state/disabled-feature/intake-dark.webp rename to apps/web/app/assets/empty-state/disabled-feature/intake-dark.webp diff --git a/apps/web/public/empty-state/disabled-feature/intake-light.webp b/apps/web/app/assets/empty-state/disabled-feature/intake-light.webp similarity index 100% rename from apps/web/public/empty-state/disabled-feature/intake-light.webp rename to apps/web/app/assets/empty-state/disabled-feature/intake-light.webp diff --git a/apps/web/public/empty-state/disabled-feature/modules-dark.webp b/apps/web/app/assets/empty-state/disabled-feature/modules-dark.webp similarity index 100% rename from apps/web/public/empty-state/disabled-feature/modules-dark.webp rename to apps/web/app/assets/empty-state/disabled-feature/modules-dark.webp diff --git a/apps/web/public/empty-state/disabled-feature/modules-light.webp b/apps/web/app/assets/empty-state/disabled-feature/modules-light.webp similarity index 100% rename from apps/web/public/empty-state/disabled-feature/modules-light.webp rename to apps/web/app/assets/empty-state/disabled-feature/modules-light.webp diff --git a/apps/web/public/empty-state/disabled-feature/pages-dark.webp b/apps/web/app/assets/empty-state/disabled-feature/pages-dark.webp similarity index 100% rename from apps/web/public/empty-state/disabled-feature/pages-dark.webp rename to apps/web/app/assets/empty-state/disabled-feature/pages-dark.webp diff --git a/apps/web/public/empty-state/disabled-feature/pages-light.webp b/apps/web/app/assets/empty-state/disabled-feature/pages-light.webp similarity index 100% rename from apps/web/public/empty-state/disabled-feature/pages-light.webp rename to apps/web/app/assets/empty-state/disabled-feature/pages-light.webp diff --git a/apps/web/public/empty-state/disabled-feature/views-dark.webp b/apps/web/app/assets/empty-state/disabled-feature/views-dark.webp similarity index 100% rename from apps/web/public/empty-state/disabled-feature/views-dark.webp rename to apps/web/app/assets/empty-state/disabled-feature/views-dark.webp diff --git a/apps/web/public/empty-state/disabled-feature/views-light.webp b/apps/web/app/assets/empty-state/disabled-feature/views-light.webp similarity index 100% rename from apps/web/public/empty-state/disabled-feature/views-light.webp rename to apps/web/app/assets/empty-state/disabled-feature/views-light.webp diff --git a/apps/web/public/empty-state/draft/draft-issues-empty-dark.webp b/apps/web/app/assets/empty-state/draft/draft-issues-empty-dark.webp similarity index 100% rename from apps/web/public/empty-state/draft/draft-issues-empty-dark.webp rename to apps/web/app/assets/empty-state/draft/draft-issues-empty-dark.webp diff --git a/apps/web/public/empty-state/draft/draft-issues-empty-light.webp b/apps/web/app/assets/empty-state/draft/draft-issues-empty-light.webp similarity index 100% rename from apps/web/public/empty-state/draft/draft-issues-empty-light.webp rename to apps/web/app/assets/empty-state/draft/draft-issues-empty-light.webp diff --git a/apps/web/public/empty-state/empty-filters/calendar-dark.webp b/apps/web/app/assets/empty-state/empty-filters/calendar-dark.webp similarity index 100% rename from apps/web/public/empty-state/empty-filters/calendar-dark.webp rename to apps/web/app/assets/empty-state/empty-filters/calendar-dark.webp diff --git a/apps/web/public/empty-state/empty-filters/calendar-light.webp b/apps/web/app/assets/empty-state/empty-filters/calendar-light.webp similarity index 100% rename from apps/web/public/empty-state/empty-filters/calendar-light.webp rename to apps/web/app/assets/empty-state/empty-filters/calendar-light.webp diff --git a/apps/web/public/empty-state/empty-filters/gantt_chart-dark.webp b/apps/web/app/assets/empty-state/empty-filters/gantt_chart-dark.webp similarity index 100% rename from apps/web/public/empty-state/empty-filters/gantt_chart-dark.webp rename to apps/web/app/assets/empty-state/empty-filters/gantt_chart-dark.webp diff --git a/apps/web/public/empty-state/empty-filters/gantt_chart-light.webp b/apps/web/app/assets/empty-state/empty-filters/gantt_chart-light.webp similarity index 100% rename from apps/web/public/empty-state/empty-filters/gantt_chart-light.webp rename to apps/web/app/assets/empty-state/empty-filters/gantt_chart-light.webp diff --git a/apps/web/public/empty-state/empty-filters/kanban-dark.webp b/apps/web/app/assets/empty-state/empty-filters/kanban-dark.webp similarity index 100% rename from apps/web/public/empty-state/empty-filters/kanban-dark.webp rename to apps/web/app/assets/empty-state/empty-filters/kanban-dark.webp diff --git a/apps/web/public/empty-state/empty-filters/kanban-light.webp b/apps/web/app/assets/empty-state/empty-filters/kanban-light.webp similarity index 100% rename from apps/web/public/empty-state/empty-filters/kanban-light.webp rename to apps/web/app/assets/empty-state/empty-filters/kanban-light.webp diff --git a/apps/web/public/empty-state/empty-filters/list-dark.webp b/apps/web/app/assets/empty-state/empty-filters/list-dark.webp similarity index 100% rename from apps/web/public/empty-state/empty-filters/list-dark.webp rename to apps/web/app/assets/empty-state/empty-filters/list-dark.webp diff --git a/apps/web/public/empty-state/empty-filters/list-light.webp b/apps/web/app/assets/empty-state/empty-filters/list-light.webp similarity index 100% rename from apps/web/public/empty-state/empty-filters/list-light.webp rename to apps/web/app/assets/empty-state/empty-filters/list-light.webp diff --git a/apps/web/public/empty-state/empty-filters/spreadsheet-dark.webp b/apps/web/app/assets/empty-state/empty-filters/spreadsheet-dark.webp similarity index 100% rename from apps/web/public/empty-state/empty-filters/spreadsheet-dark.webp rename to apps/web/app/assets/empty-state/empty-filters/spreadsheet-dark.webp diff --git a/apps/web/public/empty-state/empty-filters/spreadsheet-light.webp b/apps/web/app/assets/empty-state/empty-filters/spreadsheet-light.webp similarity index 100% rename from apps/web/public/empty-state/empty-filters/spreadsheet-light.webp rename to apps/web/app/assets/empty-state/empty-filters/spreadsheet-light.webp diff --git a/apps/web/public/empty-state/empty-updates-light.png b/apps/web/app/assets/empty-state/empty-updates-light.png similarity index 100% rename from apps/web/public/empty-state/empty-updates-light.png rename to apps/web/app/assets/empty-state/empty-updates-light.png diff --git a/apps/web/public/empty-state/empty_analytics.webp b/apps/web/app/assets/empty-state/empty_analytics.webp similarity index 100% rename from apps/web/public/empty-state/empty_analytics.webp rename to apps/web/app/assets/empty-state/empty_analytics.webp diff --git a/apps/web/public/empty-state/empty_bar_graph.svg b/apps/web/app/assets/empty-state/empty_bar_graph.svg similarity index 100% rename from apps/web/public/empty-state/empty_bar_graph.svg rename to apps/web/app/assets/empty-state/empty_bar_graph.svg diff --git a/apps/web/public/empty-state/empty_cycles.webp b/apps/web/app/assets/empty-state/empty_cycles.webp similarity index 100% rename from apps/web/public/empty-state/empty_cycles.webp rename to apps/web/app/assets/empty-state/empty_cycles.webp diff --git a/apps/web/public/empty-state/empty_graph.svg b/apps/web/app/assets/empty-state/empty_graph.svg similarity index 100% rename from apps/web/public/empty-state/empty_graph.svg rename to apps/web/app/assets/empty-state/empty_graph.svg diff --git a/apps/web/public/empty-state/empty_issues.webp b/apps/web/app/assets/empty-state/empty_issues.webp similarity index 100% rename from apps/web/public/empty-state/empty_issues.webp rename to apps/web/app/assets/empty-state/empty_issues.webp diff --git a/apps/web/public/empty-state/empty_label.svg b/apps/web/app/assets/empty-state/empty_label.svg similarity index 100% rename from apps/web/public/empty-state/empty_label.svg rename to apps/web/app/assets/empty-state/empty_label.svg diff --git a/apps/web/public/empty-state/empty_members.svg b/apps/web/app/assets/empty-state/empty_members.svg similarity index 100% rename from apps/web/public/empty-state/empty_members.svg rename to apps/web/app/assets/empty-state/empty_members.svg diff --git a/apps/web/public/empty-state/empty_modules.webp b/apps/web/app/assets/empty-state/empty_modules.webp similarity index 100% rename from apps/web/public/empty-state/empty_modules.webp rename to apps/web/app/assets/empty-state/empty_modules.webp diff --git a/apps/web/public/empty-state/empty_page.png b/apps/web/app/assets/empty-state/empty_page.png similarity index 100% rename from apps/web/public/empty-state/empty_page.png rename to apps/web/app/assets/empty-state/empty_page.png diff --git a/apps/web/public/empty-state/empty_project.webp b/apps/web/app/assets/empty-state/empty_project.webp similarity index 100% rename from apps/web/public/empty-state/empty_project.webp rename to apps/web/app/assets/empty-state/empty_project.webp diff --git a/apps/web/public/empty-state/empty_users.svg b/apps/web/app/assets/empty-state/empty_users.svg similarity index 100% rename from apps/web/public/empty-state/empty_users.svg rename to apps/web/app/assets/empty-state/empty_users.svg diff --git a/apps/web/public/empty-state/empty_view.webp b/apps/web/app/assets/empty-state/empty_view.webp similarity index 100% rename from apps/web/public/empty-state/empty_view.webp rename to apps/web/app/assets/empty-state/empty_view.webp diff --git a/apps/web/public/empty-state/epics/epics-dark.webp b/apps/web/app/assets/empty-state/epics/epics-dark.webp similarity index 100% rename from apps/web/public/empty-state/epics/epics-dark.webp rename to apps/web/app/assets/empty-state/epics/epics-dark.webp diff --git a/apps/web/public/empty-state/epics/epics-light.webp b/apps/web/app/assets/empty-state/epics/epics-light.webp similarity index 100% rename from apps/web/public/empty-state/epics/epics-light.webp rename to apps/web/app/assets/empty-state/epics/epics-light.webp diff --git a/apps/web/public/empty-state/epics/settings-dark.webp b/apps/web/app/assets/empty-state/epics/settings-dark.webp similarity index 100% rename from apps/web/public/empty-state/epics/settings-dark.webp rename to apps/web/app/assets/empty-state/epics/settings-dark.webp diff --git a/apps/web/public/empty-state/epics/settings-light.webp b/apps/web/app/assets/empty-state/epics/settings-light.webp similarity index 100% rename from apps/web/public/empty-state/epics/settings-light.webp rename to apps/web/app/assets/empty-state/epics/settings-light.webp diff --git a/apps/web/public/empty-state/estimates/dark.svg b/apps/web/app/assets/empty-state/estimates/dark.svg similarity index 100% rename from apps/web/public/empty-state/estimates/dark.svg rename to apps/web/app/assets/empty-state/estimates/dark.svg diff --git a/apps/web/public/empty-state/estimates/light.svg b/apps/web/app/assets/empty-state/estimates/light.svg similarity index 100% rename from apps/web/public/empty-state/estimates/light.svg rename to apps/web/app/assets/empty-state/estimates/light.svg diff --git a/apps/web/public/empty-state/intake/filter-issue-dark.webp b/apps/web/app/assets/empty-state/intake/filter-issue-dark.webp similarity index 100% rename from apps/web/public/empty-state/intake/filter-issue-dark.webp rename to apps/web/app/assets/empty-state/intake/filter-issue-dark.webp diff --git a/apps/web/public/empty-state/intake/filter-issue-light.webp b/apps/web/app/assets/empty-state/intake/filter-issue-light.webp similarity index 100% rename from apps/web/public/empty-state/intake/filter-issue-light.webp rename to apps/web/app/assets/empty-state/intake/filter-issue-light.webp diff --git a/apps/web/public/empty-state/intake/intake-dark-resp.webp b/apps/web/app/assets/empty-state/intake/intake-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/intake/intake-dark-resp.webp rename to apps/web/app/assets/empty-state/intake/intake-dark-resp.webp diff --git a/apps/web/public/empty-state/intake/intake-dark.webp b/apps/web/app/assets/empty-state/intake/intake-dark.webp similarity index 100% rename from apps/web/public/empty-state/intake/intake-dark.webp rename to apps/web/app/assets/empty-state/intake/intake-dark.webp diff --git a/apps/web/public/empty-state/intake/intake-issue-dark.webp b/apps/web/app/assets/empty-state/intake/intake-issue-dark.webp similarity index 100% rename from apps/web/public/empty-state/intake/intake-issue-dark.webp rename to apps/web/app/assets/empty-state/intake/intake-issue-dark.webp diff --git a/apps/web/public/empty-state/intake/intake-issue-light.webp b/apps/web/app/assets/empty-state/intake/intake-issue-light.webp similarity index 100% rename from apps/web/public/empty-state/intake/intake-issue-light.webp rename to apps/web/app/assets/empty-state/intake/intake-issue-light.webp diff --git a/apps/web/public/empty-state/intake/intake-light-resp.webp b/apps/web/app/assets/empty-state/intake/intake-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/intake/intake-light-resp.webp rename to apps/web/app/assets/empty-state/intake/intake-light-resp.webp diff --git a/apps/web/public/empty-state/intake/intake-light.webp b/apps/web/app/assets/empty-state/intake/intake-light.webp similarity index 100% rename from apps/web/public/empty-state/intake/intake-light.webp rename to apps/web/app/assets/empty-state/intake/intake-light.webp diff --git a/apps/web/public/empty-state/intake/issue-detail-dark.webp b/apps/web/app/assets/empty-state/intake/issue-detail-dark.webp similarity index 100% rename from apps/web/public/empty-state/intake/issue-detail-dark.webp rename to apps/web/app/assets/empty-state/intake/issue-detail-dark.webp diff --git a/apps/web/public/empty-state/intake/issue-detail-light.webp b/apps/web/app/assets/empty-state/intake/issue-detail-light.webp similarity index 100% rename from apps/web/public/empty-state/intake/issue-detail-light.webp rename to apps/web/app/assets/empty-state/intake/issue-detail-light.webp diff --git a/apps/web/public/empty-state/invitation.svg b/apps/web/app/assets/empty-state/invitation.svg similarity index 100% rename from apps/web/public/empty-state/invitation.svg rename to apps/web/app/assets/empty-state/invitation.svg diff --git a/apps/web/public/empty-state/issue.svg b/apps/web/app/assets/empty-state/issue.svg similarity index 100% rename from apps/web/public/empty-state/issue.svg rename to apps/web/app/assets/empty-state/issue.svg diff --git a/apps/web/public/empty-state/label.svg b/apps/web/app/assets/empty-state/label.svg similarity index 100% rename from apps/web/public/empty-state/label.svg rename to apps/web/app/assets/empty-state/label.svg diff --git a/apps/web/public/empty-state/module-issues/calendar-dark-resp.webp b/apps/web/app/assets/empty-state/module-issues/calendar-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/calendar-dark-resp.webp rename to apps/web/app/assets/empty-state/module-issues/calendar-dark-resp.webp diff --git a/apps/web/public/empty-state/module-issues/calendar-dark.webp b/apps/web/app/assets/empty-state/module-issues/calendar-dark.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/calendar-dark.webp rename to apps/web/app/assets/empty-state/module-issues/calendar-dark.webp diff --git a/apps/web/public/empty-state/module-issues/calendar-light-resp.webp b/apps/web/app/assets/empty-state/module-issues/calendar-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/calendar-light-resp.webp rename to apps/web/app/assets/empty-state/module-issues/calendar-light-resp.webp diff --git a/apps/web/public/empty-state/module-issues/calendar-light.webp b/apps/web/app/assets/empty-state/module-issues/calendar-light.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/calendar-light.webp rename to apps/web/app/assets/empty-state/module-issues/calendar-light.webp diff --git a/apps/web/public/empty-state/module-issues/gantt_chart-dark-resp.webp b/apps/web/app/assets/empty-state/module-issues/gantt_chart-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/gantt_chart-dark-resp.webp rename to apps/web/app/assets/empty-state/module-issues/gantt_chart-dark-resp.webp diff --git a/apps/web/public/empty-state/module-issues/gantt_chart-dark.webp b/apps/web/app/assets/empty-state/module-issues/gantt_chart-dark.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/gantt_chart-dark.webp rename to apps/web/app/assets/empty-state/module-issues/gantt_chart-dark.webp diff --git a/apps/web/public/empty-state/module-issues/gantt_chart-light-resp.webp b/apps/web/app/assets/empty-state/module-issues/gantt_chart-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/gantt_chart-light-resp.webp rename to apps/web/app/assets/empty-state/module-issues/gantt_chart-light-resp.webp diff --git a/apps/web/public/empty-state/module-issues/gantt_chart-light.webp b/apps/web/app/assets/empty-state/module-issues/gantt_chart-light.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/gantt_chart-light.webp rename to apps/web/app/assets/empty-state/module-issues/gantt_chart-light.webp diff --git a/apps/web/public/empty-state/module-issues/kanban-dark-resp.webp b/apps/web/app/assets/empty-state/module-issues/kanban-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/kanban-dark-resp.webp rename to apps/web/app/assets/empty-state/module-issues/kanban-dark-resp.webp diff --git a/apps/web/public/empty-state/module-issues/kanban-dark.webp b/apps/web/app/assets/empty-state/module-issues/kanban-dark.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/kanban-dark.webp rename to apps/web/app/assets/empty-state/module-issues/kanban-dark.webp diff --git a/apps/web/public/empty-state/module-issues/kanban-light-resp.webp b/apps/web/app/assets/empty-state/module-issues/kanban-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/kanban-light-resp.webp rename to apps/web/app/assets/empty-state/module-issues/kanban-light-resp.webp diff --git a/apps/web/public/empty-state/module-issues/kanban-light.webp b/apps/web/app/assets/empty-state/module-issues/kanban-light.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/kanban-light.webp rename to apps/web/app/assets/empty-state/module-issues/kanban-light.webp diff --git a/apps/web/public/empty-state/module-issues/list-dark-resp.webp b/apps/web/app/assets/empty-state/module-issues/list-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/list-dark-resp.webp rename to apps/web/app/assets/empty-state/module-issues/list-dark-resp.webp diff --git a/apps/web/public/empty-state/module-issues/list-dark.webp b/apps/web/app/assets/empty-state/module-issues/list-dark.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/list-dark.webp rename to apps/web/app/assets/empty-state/module-issues/list-dark.webp diff --git a/apps/web/public/empty-state/module-issues/list-light-resp.webp b/apps/web/app/assets/empty-state/module-issues/list-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/list-light-resp.webp rename to apps/web/app/assets/empty-state/module-issues/list-light-resp.webp diff --git a/apps/web/public/empty-state/module-issues/list-light.webp b/apps/web/app/assets/empty-state/module-issues/list-light.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/list-light.webp rename to apps/web/app/assets/empty-state/module-issues/list-light.webp diff --git a/apps/web/public/empty-state/module-issues/spreadsheet-dark-resp.webp b/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/spreadsheet-dark-resp.webp rename to apps/web/app/assets/empty-state/module-issues/spreadsheet-dark-resp.webp diff --git a/apps/web/public/empty-state/module-issues/spreadsheet-dark.webp b/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/spreadsheet-dark.webp rename to apps/web/app/assets/empty-state/module-issues/spreadsheet-dark.webp diff --git a/apps/web/public/empty-state/module-issues/spreadsheet-light-resp.webp b/apps/web/app/assets/empty-state/module-issues/spreadsheet-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/spreadsheet-light-resp.webp rename to apps/web/app/assets/empty-state/module-issues/spreadsheet-light-resp.webp diff --git a/apps/web/public/empty-state/module-issues/spreadsheet-light.webp b/apps/web/app/assets/empty-state/module-issues/spreadsheet-light.webp similarity index 100% rename from apps/web/public/empty-state/module-issues/spreadsheet-light.webp rename to apps/web/app/assets/empty-state/module-issues/spreadsheet-light.webp diff --git a/apps/web/public/empty-state/module.svg b/apps/web/app/assets/empty-state/module.svg similarity index 100% rename from apps/web/public/empty-state/module.svg rename to apps/web/app/assets/empty-state/module.svg diff --git a/apps/web/public/empty-state/module/all-filters.svg b/apps/web/app/assets/empty-state/module/all-filters.svg similarity index 100% rename from apps/web/public/empty-state/module/all-filters.svg rename to apps/web/app/assets/empty-state/module/all-filters.svg diff --git a/apps/web/public/empty-state/module/name-filter.svg b/apps/web/app/assets/empty-state/module/name-filter.svg similarity index 100% rename from apps/web/public/empty-state/module/name-filter.svg rename to apps/web/app/assets/empty-state/module/name-filter.svg diff --git a/apps/web/public/empty-state/notification.svg b/apps/web/app/assets/empty-state/notification.svg similarity index 100% rename from apps/web/public/empty-state/notification.svg rename to apps/web/app/assets/empty-state/notification.svg diff --git a/apps/web/public/empty-state/onboarding/analytics-dark.webp b/apps/web/app/assets/empty-state/onboarding/analytics-dark.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/analytics-dark.webp rename to apps/web/app/assets/empty-state/onboarding/analytics-dark.webp diff --git a/apps/web/public/empty-state/onboarding/analytics-light.webp b/apps/web/app/assets/empty-state/onboarding/analytics-light.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/analytics-light.webp rename to apps/web/app/assets/empty-state/onboarding/analytics-light.webp diff --git a/apps/web/public/empty-state/onboarding/archive-dark.png b/apps/web/app/assets/empty-state/onboarding/archive-dark.png similarity index 100% rename from apps/web/public/empty-state/onboarding/archive-dark.png rename to apps/web/app/assets/empty-state/onboarding/archive-dark.png diff --git a/apps/web/public/empty-state/onboarding/archive-light.png b/apps/web/app/assets/empty-state/onboarding/archive-light.png similarity index 100% rename from apps/web/public/empty-state/onboarding/archive-light.png rename to apps/web/app/assets/empty-state/onboarding/archive-light.png diff --git a/apps/web/public/empty-state/onboarding/cycles-dark.webp b/apps/web/app/assets/empty-state/onboarding/cycles-dark.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/cycles-dark.webp rename to apps/web/app/assets/empty-state/onboarding/cycles-dark.webp diff --git a/apps/web/public/empty-state/onboarding/cycles-light.webp b/apps/web/app/assets/empty-state/onboarding/cycles-light.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/cycles-light.webp rename to apps/web/app/assets/empty-state/onboarding/cycles-light.webp diff --git a/apps/web/public/empty-state/onboarding/dashboard-dark.webp b/apps/web/app/assets/empty-state/onboarding/dashboard-dark.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/dashboard-dark.webp rename to apps/web/app/assets/empty-state/onboarding/dashboard-dark.webp diff --git a/apps/web/public/empty-state/onboarding/dashboard-light.webp b/apps/web/app/assets/empty-state/onboarding/dashboard-light.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/dashboard-light.webp rename to apps/web/app/assets/empty-state/onboarding/dashboard-light.webp diff --git a/apps/web/public/empty-state/onboarding/graph-dark.png b/apps/web/app/assets/empty-state/onboarding/graph-dark.png similarity index 100% rename from apps/web/public/empty-state/onboarding/graph-dark.png rename to apps/web/app/assets/empty-state/onboarding/graph-dark.png diff --git a/apps/web/public/empty-state/onboarding/graph-light.png b/apps/web/app/assets/empty-state/onboarding/graph-light.png similarity index 100% rename from apps/web/public/empty-state/onboarding/graph-light.png rename to apps/web/app/assets/empty-state/onboarding/graph-light.png diff --git a/apps/web/public/empty-state/onboarding/issues-closed-dark.png b/apps/web/app/assets/empty-state/onboarding/issues-closed-dark.png similarity index 100% rename from apps/web/public/empty-state/onboarding/issues-closed-dark.png rename to apps/web/app/assets/empty-state/onboarding/issues-closed-dark.png diff --git a/apps/web/public/empty-state/onboarding/issues-closed-light.png b/apps/web/app/assets/empty-state/onboarding/issues-closed-light.png similarity index 100% rename from apps/web/public/empty-state/onboarding/issues-closed-light.png rename to apps/web/app/assets/empty-state/onboarding/issues-closed-light.png diff --git a/apps/web/public/empty-state/onboarding/issues-dark.webp b/apps/web/app/assets/empty-state/onboarding/issues-dark.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/issues-dark.webp rename to apps/web/app/assets/empty-state/onboarding/issues-dark.webp diff --git a/apps/web/public/empty-state/onboarding/issues-light.webp b/apps/web/app/assets/empty-state/onboarding/issues-light.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/issues-light.webp rename to apps/web/app/assets/empty-state/onboarding/issues-light.webp diff --git a/apps/web/public/empty-state/onboarding/members-dark.png b/apps/web/app/assets/empty-state/onboarding/members-dark.png similarity index 100% rename from apps/web/public/empty-state/onboarding/members-dark.png rename to apps/web/app/assets/empty-state/onboarding/members-dark.png diff --git a/apps/web/public/empty-state/onboarding/members-light.png b/apps/web/app/assets/empty-state/onboarding/members-light.png similarity index 100% rename from apps/web/public/empty-state/onboarding/members-light.png rename to apps/web/app/assets/empty-state/onboarding/members-light.png diff --git a/apps/web/public/empty-state/onboarding/modules-dark.webp b/apps/web/app/assets/empty-state/onboarding/modules-dark.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/modules-dark.webp rename to apps/web/app/assets/empty-state/onboarding/modules-dark.webp diff --git a/apps/web/public/empty-state/onboarding/modules-light.webp b/apps/web/app/assets/empty-state/onboarding/modules-light.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/modules-light.webp rename to apps/web/app/assets/empty-state/onboarding/modules-light.webp diff --git a/apps/web/public/empty-state/onboarding/notification-dark.png b/apps/web/app/assets/empty-state/onboarding/notification-dark.png similarity index 100% rename from apps/web/public/empty-state/onboarding/notification-dark.png rename to apps/web/app/assets/empty-state/onboarding/notification-dark.png diff --git a/apps/web/public/empty-state/onboarding/notification-light.png b/apps/web/app/assets/empty-state/onboarding/notification-light.png similarity index 100% rename from apps/web/public/empty-state/onboarding/notification-light.png rename to apps/web/app/assets/empty-state/onboarding/notification-light.png diff --git a/apps/web/public/empty-state/onboarding/pages-dark.webp b/apps/web/app/assets/empty-state/onboarding/pages-dark.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/pages-dark.webp rename to apps/web/app/assets/empty-state/onboarding/pages-dark.webp diff --git a/apps/web/public/empty-state/onboarding/pages-light.webp b/apps/web/app/assets/empty-state/onboarding/pages-light.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/pages-light.webp rename to apps/web/app/assets/empty-state/onboarding/pages-light.webp diff --git a/apps/web/public/empty-state/onboarding/projects-dark.webp b/apps/web/app/assets/empty-state/onboarding/projects-dark.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/projects-dark.webp rename to apps/web/app/assets/empty-state/onboarding/projects-dark.webp diff --git a/apps/web/public/empty-state/onboarding/projects-light.webp b/apps/web/app/assets/empty-state/onboarding/projects-light.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/projects-light.webp rename to apps/web/app/assets/empty-state/onboarding/projects-light.webp diff --git a/apps/web/public/empty-state/onboarding/search-dark.png b/apps/web/app/assets/empty-state/onboarding/search-dark.png similarity index 100% rename from apps/web/public/empty-state/onboarding/search-dark.png rename to apps/web/app/assets/empty-state/onboarding/search-dark.png diff --git a/apps/web/public/empty-state/onboarding/search-light.png b/apps/web/app/assets/empty-state/onboarding/search-light.png similarity index 100% rename from apps/web/public/empty-state/onboarding/search-light.png rename to apps/web/app/assets/empty-state/onboarding/search-light.png diff --git a/apps/web/public/empty-state/onboarding/snooze-light.png b/apps/web/app/assets/empty-state/onboarding/snooze-light.png similarity index 100% rename from apps/web/public/empty-state/onboarding/snooze-light.png rename to apps/web/app/assets/empty-state/onboarding/snooze-light.png diff --git a/apps/web/public/empty-state/onboarding/snoozed-dark.png b/apps/web/app/assets/empty-state/onboarding/snoozed-dark.png similarity index 100% rename from apps/web/public/empty-state/onboarding/snoozed-dark.png rename to apps/web/app/assets/empty-state/onboarding/snoozed-dark.png diff --git a/apps/web/public/empty-state/onboarding/views-dark.webp b/apps/web/app/assets/empty-state/onboarding/views-dark.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/views-dark.webp rename to apps/web/app/assets/empty-state/onboarding/views-dark.webp diff --git a/apps/web/public/empty-state/onboarding/views-light.webp b/apps/web/app/assets/empty-state/onboarding/views-light.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/views-light.webp rename to apps/web/app/assets/empty-state/onboarding/views-light.webp diff --git a/apps/web/public/empty-state/onboarding/workspace-active-cycles-dark.webp b/apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-dark.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/workspace-active-cycles-dark.webp rename to apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-dark.webp diff --git a/apps/web/public/empty-state/onboarding/workspace-active-cycles-light.webp b/apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-light.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/workspace-active-cycles-light.webp rename to apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-light.webp diff --git a/apps/web/public/empty-state/onboarding/workspace-invites-dark.webp b/apps/web/app/assets/empty-state/onboarding/workspace-invites-dark.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/workspace-invites-dark.webp rename to apps/web/app/assets/empty-state/onboarding/workspace-invites-dark.webp diff --git a/apps/web/public/empty-state/onboarding/workspace-invites-light.webp b/apps/web/app/assets/empty-state/onboarding/workspace-invites-light.webp similarity index 100% rename from apps/web/public/empty-state/onboarding/workspace-invites-light.webp rename to apps/web/app/assets/empty-state/onboarding/workspace-invites-light.webp diff --git a/apps/web/public/empty-state/profile/activities-dark.webp b/apps/web/app/assets/empty-state/profile/activities-dark.webp similarity index 100% rename from apps/web/public/empty-state/profile/activities-dark.webp rename to apps/web/app/assets/empty-state/profile/activities-dark.webp diff --git a/apps/web/public/empty-state/profile/activities-light.webp b/apps/web/app/assets/empty-state/profile/activities-light.webp similarity index 100% rename from apps/web/public/empty-state/profile/activities-light.webp rename to apps/web/app/assets/empty-state/profile/activities-light.webp diff --git a/apps/web/public/empty-state/profile/activity-dark.webp b/apps/web/app/assets/empty-state/profile/activity-dark.webp similarity index 100% rename from apps/web/public/empty-state/profile/activity-dark.webp rename to apps/web/app/assets/empty-state/profile/activity-dark.webp diff --git a/apps/web/public/empty-state/profile/activity-light.webp b/apps/web/app/assets/empty-state/profile/activity-light.webp similarity index 100% rename from apps/web/public/empty-state/profile/activity-light.webp rename to apps/web/app/assets/empty-state/profile/activity-light.webp diff --git a/apps/web/public/empty-state/profile/assigned-dark.webp b/apps/web/app/assets/empty-state/profile/assigned-dark.webp similarity index 100% rename from apps/web/public/empty-state/profile/assigned-dark.webp rename to apps/web/app/assets/empty-state/profile/assigned-dark.webp diff --git a/apps/web/public/empty-state/profile/assigned-light.webp b/apps/web/app/assets/empty-state/profile/assigned-light.webp similarity index 100% rename from apps/web/public/empty-state/profile/assigned-light.webp rename to apps/web/app/assets/empty-state/profile/assigned-light.webp diff --git a/apps/web/public/empty-state/profile/created-dark.webp b/apps/web/app/assets/empty-state/profile/created-dark.webp similarity index 100% rename from apps/web/public/empty-state/profile/created-dark.webp rename to apps/web/app/assets/empty-state/profile/created-dark.webp diff --git a/apps/web/public/empty-state/profile/created-light.webp b/apps/web/app/assets/empty-state/profile/created-light.webp similarity index 100% rename from apps/web/public/empty-state/profile/created-light.webp rename to apps/web/app/assets/empty-state/profile/created-light.webp diff --git a/apps/web/public/empty-state/profile/issues-by-priority-dark.webp b/apps/web/app/assets/empty-state/profile/issues-by-priority-dark.webp similarity index 100% rename from apps/web/public/empty-state/profile/issues-by-priority-dark.webp rename to apps/web/app/assets/empty-state/profile/issues-by-priority-dark.webp diff --git a/apps/web/public/empty-state/profile/issues-by-priority-light.webp b/apps/web/app/assets/empty-state/profile/issues-by-priority-light.webp similarity index 100% rename from apps/web/public/empty-state/profile/issues-by-priority-light.webp rename to apps/web/app/assets/empty-state/profile/issues-by-priority-light.webp diff --git a/apps/web/public/empty-state/profile/issues-by-state-dark.webp b/apps/web/app/assets/empty-state/profile/issues-by-state-dark.webp similarity index 100% rename from apps/web/public/empty-state/profile/issues-by-state-dark.webp rename to apps/web/app/assets/empty-state/profile/issues-by-state-dark.webp diff --git a/apps/web/public/empty-state/profile/issues-by-state-light.webp b/apps/web/app/assets/empty-state/profile/issues-by-state-light.webp similarity index 100% rename from apps/web/public/empty-state/profile/issues-by-state-light.webp rename to apps/web/app/assets/empty-state/profile/issues-by-state-light.webp diff --git a/apps/web/public/empty-state/profile/subscribed-dark.webp b/apps/web/app/assets/empty-state/profile/subscribed-dark.webp similarity index 100% rename from apps/web/public/empty-state/profile/subscribed-dark.webp rename to apps/web/app/assets/empty-state/profile/subscribed-dark.webp diff --git a/apps/web/public/empty-state/profile/subscribed-light.webp b/apps/web/app/assets/empty-state/profile/subscribed-light.webp similarity index 100% rename from apps/web/public/empty-state/profile/subscribed-light.webp rename to apps/web/app/assets/empty-state/profile/subscribed-light.webp diff --git a/apps/web/public/empty-state/project-settings/estimates-dark-resp.webp b/apps/web/app/assets/empty-state/project-settings/estimates-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/project-settings/estimates-dark-resp.webp rename to apps/web/app/assets/empty-state/project-settings/estimates-dark-resp.webp diff --git a/apps/web/public/empty-state/project-settings/estimates-dark.png b/apps/web/app/assets/empty-state/project-settings/estimates-dark.png similarity index 100% rename from apps/web/public/empty-state/project-settings/estimates-dark.png rename to apps/web/app/assets/empty-state/project-settings/estimates-dark.png diff --git a/apps/web/public/empty-state/project-settings/estimates-dark.webp b/apps/web/app/assets/empty-state/project-settings/estimates-dark.webp similarity index 100% rename from apps/web/public/empty-state/project-settings/estimates-dark.webp rename to apps/web/app/assets/empty-state/project-settings/estimates-dark.webp diff --git a/apps/web/public/empty-state/project-settings/estimates-light-resp.webp b/apps/web/app/assets/empty-state/project-settings/estimates-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/project-settings/estimates-light-resp.webp rename to apps/web/app/assets/empty-state/project-settings/estimates-light-resp.webp diff --git a/apps/web/public/empty-state/project-settings/estimates-light.png b/apps/web/app/assets/empty-state/project-settings/estimates-light.png similarity index 100% rename from apps/web/public/empty-state/project-settings/estimates-light.png rename to apps/web/app/assets/empty-state/project-settings/estimates-light.png diff --git a/apps/web/public/empty-state/project-settings/estimates-light.webp b/apps/web/app/assets/empty-state/project-settings/estimates-light.webp similarity index 100% rename from apps/web/public/empty-state/project-settings/estimates-light.webp rename to apps/web/app/assets/empty-state/project-settings/estimates-light.webp diff --git a/apps/web/public/empty-state/project-settings/integrations-dark-resp.webp b/apps/web/app/assets/empty-state/project-settings/integrations-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/project-settings/integrations-dark-resp.webp rename to apps/web/app/assets/empty-state/project-settings/integrations-dark-resp.webp diff --git a/apps/web/public/empty-state/project-settings/integrations-dark.webp b/apps/web/app/assets/empty-state/project-settings/integrations-dark.webp similarity index 100% rename from apps/web/public/empty-state/project-settings/integrations-dark.webp rename to apps/web/app/assets/empty-state/project-settings/integrations-dark.webp diff --git a/apps/web/public/empty-state/project-settings/integrations-light-resp.webp b/apps/web/app/assets/empty-state/project-settings/integrations-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/project-settings/integrations-light-resp.webp rename to apps/web/app/assets/empty-state/project-settings/integrations-light-resp.webp diff --git a/apps/web/public/empty-state/project-settings/integrations-light.webp b/apps/web/app/assets/empty-state/project-settings/integrations-light.webp similarity index 100% rename from apps/web/public/empty-state/project-settings/integrations-light.webp rename to apps/web/app/assets/empty-state/project-settings/integrations-light.webp diff --git a/apps/web/public/empty-state/project-settings/labels-dark-resp.webp b/apps/web/app/assets/empty-state/project-settings/labels-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/project-settings/labels-dark-resp.webp rename to apps/web/app/assets/empty-state/project-settings/labels-dark-resp.webp diff --git a/apps/web/public/empty-state/project-settings/labels-dark.webp b/apps/web/app/assets/empty-state/project-settings/labels-dark.webp similarity index 100% rename from apps/web/public/empty-state/project-settings/labels-dark.webp rename to apps/web/app/assets/empty-state/project-settings/labels-dark.webp diff --git a/apps/web/public/empty-state/project-settings/labels-light-resp.webp b/apps/web/app/assets/empty-state/project-settings/labels-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/project-settings/labels-light-resp.webp rename to apps/web/app/assets/empty-state/project-settings/labels-light-resp.webp diff --git a/apps/web/public/empty-state/project-settings/labels-light.webp b/apps/web/app/assets/empty-state/project-settings/labels-light.webp similarity index 100% rename from apps/web/public/empty-state/project-settings/labels-light.webp rename to apps/web/app/assets/empty-state/project-settings/labels-light.webp diff --git a/apps/web/public/empty-state/project-settings/no-projects-dark.png b/apps/web/app/assets/empty-state/project-settings/no-projects-dark.png similarity index 100% rename from apps/web/public/empty-state/project-settings/no-projects-dark.png rename to apps/web/app/assets/empty-state/project-settings/no-projects-dark.png diff --git a/apps/web/public/empty-state/project-settings/no-projects-light.png b/apps/web/app/assets/empty-state/project-settings/no-projects-light.png similarity index 100% rename from apps/web/public/empty-state/project-settings/no-projects-light.png rename to apps/web/app/assets/empty-state/project-settings/no-projects-light.png diff --git a/apps/web/public/empty-state/project-settings/updates-dark.png b/apps/web/app/assets/empty-state/project-settings/updates-dark.png similarity index 100% rename from apps/web/public/empty-state/project-settings/updates-dark.png rename to apps/web/app/assets/empty-state/project-settings/updates-dark.png diff --git a/apps/web/public/empty-state/project-settings/updates-light.png b/apps/web/app/assets/empty-state/project-settings/updates-light.png similarity index 100% rename from apps/web/public/empty-state/project-settings/updates-light.png rename to apps/web/app/assets/empty-state/project-settings/updates-light.png diff --git a/apps/web/public/empty-state/project.svg b/apps/web/app/assets/empty-state/project.svg similarity index 100% rename from apps/web/public/empty-state/project.svg rename to apps/web/app/assets/empty-state/project.svg diff --git a/apps/web/public/empty-state/project/all-filters-dark.svg b/apps/web/app/assets/empty-state/project/all-filters-dark.svg similarity index 100% rename from apps/web/public/empty-state/project/all-filters-dark.svg rename to apps/web/app/assets/empty-state/project/all-filters-dark.svg diff --git a/apps/web/public/empty-state/project/all-filters-light.svg b/apps/web/app/assets/empty-state/project/all-filters-light.svg similarity index 100% rename from apps/web/public/empty-state/project/all-filters-light.svg rename to apps/web/app/assets/empty-state/project/all-filters-light.svg diff --git a/apps/web/public/empty-state/project/name-filter-dark.svg b/apps/web/app/assets/empty-state/project/name-filter-dark.svg similarity index 100% rename from apps/web/public/empty-state/project/name-filter-dark.svg rename to apps/web/app/assets/empty-state/project/name-filter-dark.svg diff --git a/apps/web/public/empty-state/project/name-filter-light.svg b/apps/web/app/assets/empty-state/project/name-filter-light.svg similarity index 100% rename from apps/web/public/empty-state/project/name-filter-light.svg rename to apps/web/app/assets/empty-state/project/name-filter-light.svg diff --git a/apps/web/public/empty-state/project/name-filter.svg b/apps/web/app/assets/empty-state/project/name-filter.svg similarity index 100% rename from apps/web/public/empty-state/project/name-filter.svg rename to apps/web/app/assets/empty-state/project/name-filter.svg diff --git a/apps/web/public/empty-state/recent_activity.svg b/apps/web/app/assets/empty-state/recent_activity.svg similarity index 100% rename from apps/web/public/empty-state/recent_activity.svg rename to apps/web/app/assets/empty-state/recent_activity.svg diff --git a/apps/web/public/empty-state/search/all-issue-view-dark.webp b/apps/web/app/assets/empty-state/search/all-issue-view-dark.webp similarity index 100% rename from apps/web/public/empty-state/search/all-issue-view-dark.webp rename to apps/web/app/assets/empty-state/search/all-issue-view-dark.webp diff --git a/apps/web/public/empty-state/search/all-issues-view-light.webp b/apps/web/app/assets/empty-state/search/all-issues-view-light.webp similarity index 100% rename from apps/web/public/empty-state/search/all-issues-view-light.webp rename to apps/web/app/assets/empty-state/search/all-issues-view-light.webp diff --git a/apps/web/public/empty-state/search/archive-dark.webp b/apps/web/app/assets/empty-state/search/archive-dark.webp similarity index 100% rename from apps/web/public/empty-state/search/archive-dark.webp rename to apps/web/app/assets/empty-state/search/archive-dark.webp diff --git a/apps/web/public/empty-state/search/archive-light.webp b/apps/web/app/assets/empty-state/search/archive-light.webp similarity index 100% rename from apps/web/public/empty-state/search/archive-light.webp rename to apps/web/app/assets/empty-state/search/archive-light.webp diff --git a/apps/web/public/empty-state/search/comments-dark.webp b/apps/web/app/assets/empty-state/search/comments-dark.webp similarity index 100% rename from apps/web/public/empty-state/search/comments-dark.webp rename to apps/web/app/assets/empty-state/search/comments-dark.webp diff --git a/apps/web/public/empty-state/search/comments-light.webp b/apps/web/app/assets/empty-state/search/comments-light.webp similarity index 100% rename from apps/web/public/empty-state/search/comments-light.webp rename to apps/web/app/assets/empty-state/search/comments-light.webp diff --git a/apps/web/public/empty-state/search/issues-dark.webp b/apps/web/app/assets/empty-state/search/issues-dark.webp similarity index 100% rename from apps/web/public/empty-state/search/issues-dark.webp rename to apps/web/app/assets/empty-state/search/issues-dark.webp diff --git a/apps/web/public/empty-state/search/issues-light.webp b/apps/web/app/assets/empty-state/search/issues-light.webp similarity index 100% rename from apps/web/public/empty-state/search/issues-light.webp rename to apps/web/app/assets/empty-state/search/issues-light.webp diff --git a/apps/web/public/empty-state/search/member-dark.webp b/apps/web/app/assets/empty-state/search/member-dark.webp similarity index 100% rename from apps/web/public/empty-state/search/member-dark.webp rename to apps/web/app/assets/empty-state/search/member-dark.webp diff --git a/apps/web/public/empty-state/search/member-light.webp b/apps/web/app/assets/empty-state/search/member-light.webp similarity index 100% rename from apps/web/public/empty-state/search/member-light.webp rename to apps/web/app/assets/empty-state/search/member-light.webp diff --git a/apps/web/public/empty-state/search/notification-dark.webp b/apps/web/app/assets/empty-state/search/notification-dark.webp similarity index 100% rename from apps/web/public/empty-state/search/notification-dark.webp rename to apps/web/app/assets/empty-state/search/notification-dark.webp diff --git a/apps/web/public/empty-state/search/notification-light.webp b/apps/web/app/assets/empty-state/search/notification-light.webp similarity index 100% rename from apps/web/public/empty-state/search/notification-light.webp rename to apps/web/app/assets/empty-state/search/notification-light.webp diff --git a/apps/web/public/empty-state/search/project-dark.webp b/apps/web/app/assets/empty-state/search/project-dark.webp similarity index 100% rename from apps/web/public/empty-state/search/project-dark.webp rename to apps/web/app/assets/empty-state/search/project-dark.webp diff --git a/apps/web/public/empty-state/search/project-light.webp b/apps/web/app/assets/empty-state/search/project-light.webp similarity index 100% rename from apps/web/public/empty-state/search/project-light.webp rename to apps/web/app/assets/empty-state/search/project-light.webp diff --git a/apps/web/public/empty-state/search/search-dark.webp b/apps/web/app/assets/empty-state/search/search-dark.webp similarity index 100% rename from apps/web/public/empty-state/search/search-dark.webp rename to apps/web/app/assets/empty-state/search/search-dark.webp diff --git a/apps/web/public/empty-state/search/search-light.webp b/apps/web/app/assets/empty-state/search/search-light.webp similarity index 100% rename from apps/web/public/empty-state/search/search-light.webp rename to apps/web/app/assets/empty-state/search/search-light.webp diff --git a/apps/web/public/empty-state/search/snooze-dark.webp b/apps/web/app/assets/empty-state/search/snooze-dark.webp similarity index 100% rename from apps/web/public/empty-state/search/snooze-dark.webp rename to apps/web/app/assets/empty-state/search/snooze-dark.webp diff --git a/apps/web/public/empty-state/search/snooze-light.webp b/apps/web/app/assets/empty-state/search/snooze-light.webp similarity index 100% rename from apps/web/public/empty-state/search/snooze-light.webp rename to apps/web/app/assets/empty-state/search/snooze-light.webp diff --git a/apps/web/public/empty-state/search/views-dark.webp b/apps/web/app/assets/empty-state/search/views-dark.webp similarity index 100% rename from apps/web/public/empty-state/search/views-dark.webp rename to apps/web/app/assets/empty-state/search/views-dark.webp diff --git a/apps/web/public/empty-state/search/views-light.webp b/apps/web/app/assets/empty-state/search/views-light.webp similarity index 100% rename from apps/web/public/empty-state/search/views-light.webp rename to apps/web/app/assets/empty-state/search/views-light.webp diff --git a/apps/web/public/empty-state/state_graph.svg b/apps/web/app/assets/empty-state/state_graph.svg similarity index 100% rename from apps/web/public/empty-state/state_graph.svg rename to apps/web/app/assets/empty-state/state_graph.svg diff --git a/apps/web/public/empty-state/stickies/stickies-dark.webp b/apps/web/app/assets/empty-state/stickies/stickies-dark.webp similarity index 100% rename from apps/web/public/empty-state/stickies/stickies-dark.webp rename to apps/web/app/assets/empty-state/stickies/stickies-dark.webp diff --git a/apps/web/public/empty-state/stickies/stickies-light.webp b/apps/web/app/assets/empty-state/stickies/stickies-light.webp similarity index 100% rename from apps/web/public/empty-state/stickies/stickies-light.webp rename to apps/web/app/assets/empty-state/stickies/stickies-light.webp diff --git a/apps/web/public/empty-state/stickies/stickies-search-dark.webp b/apps/web/app/assets/empty-state/stickies/stickies-search-dark.webp similarity index 100% rename from apps/web/public/empty-state/stickies/stickies-search-dark.webp rename to apps/web/app/assets/empty-state/stickies/stickies-search-dark.webp diff --git a/apps/web/public/empty-state/stickies/stickies-search-light.webp b/apps/web/app/assets/empty-state/stickies/stickies-search-light.webp similarity index 100% rename from apps/web/public/empty-state/stickies/stickies-search-light.webp rename to apps/web/app/assets/empty-state/stickies/stickies-search-light.webp diff --git a/apps/web/public/empty-state/view.svg b/apps/web/app/assets/empty-state/view.svg similarity index 100% rename from apps/web/public/empty-state/view.svg rename to apps/web/app/assets/empty-state/view.svg diff --git a/apps/web/public/empty-state/web-hook.svg b/apps/web/app/assets/empty-state/web-hook.svg similarity index 100% rename from apps/web/public/empty-state/web-hook.svg rename to apps/web/app/assets/empty-state/web-hook.svg diff --git a/apps/web/public/empty-state/wiki/all-dark.webp b/apps/web/app/assets/empty-state/wiki/all-dark.webp similarity index 100% rename from apps/web/public/empty-state/wiki/all-dark.webp rename to apps/web/app/assets/empty-state/wiki/all-dark.webp diff --git a/apps/web/public/empty-state/wiki/all-filters-dark.svg b/apps/web/app/assets/empty-state/wiki/all-filters-dark.svg similarity index 100% rename from apps/web/public/empty-state/wiki/all-filters-dark.svg rename to apps/web/app/assets/empty-state/wiki/all-filters-dark.svg diff --git a/apps/web/public/empty-state/wiki/all-filters-light.svg b/apps/web/app/assets/empty-state/wiki/all-filters-light.svg similarity index 100% rename from apps/web/public/empty-state/wiki/all-filters-light.svg rename to apps/web/app/assets/empty-state/wiki/all-filters-light.svg diff --git a/apps/web/public/empty-state/wiki/all-light.webp b/apps/web/app/assets/empty-state/wiki/all-light.webp similarity index 100% rename from apps/web/public/empty-state/wiki/all-light.webp rename to apps/web/app/assets/empty-state/wiki/all-light.webp diff --git a/apps/web/public/empty-state/wiki/archived-dark.webp b/apps/web/app/assets/empty-state/wiki/archived-dark.webp similarity index 100% rename from apps/web/public/empty-state/wiki/archived-dark.webp rename to apps/web/app/assets/empty-state/wiki/archived-dark.webp diff --git a/apps/web/public/empty-state/wiki/archived-light.webp b/apps/web/app/assets/empty-state/wiki/archived-light.webp similarity index 100% rename from apps/web/public/empty-state/wiki/archived-light.webp rename to apps/web/app/assets/empty-state/wiki/archived-light.webp diff --git a/apps/web/public/empty-state/wiki/name-filter-dark.svg b/apps/web/app/assets/empty-state/wiki/name-filter-dark.svg similarity index 100% rename from apps/web/public/empty-state/wiki/name-filter-dark.svg rename to apps/web/app/assets/empty-state/wiki/name-filter-dark.svg diff --git a/apps/web/public/empty-state/wiki/name-filter-light.svg b/apps/web/app/assets/empty-state/wiki/name-filter-light.svg similarity index 100% rename from apps/web/public/empty-state/wiki/name-filter-light.svg rename to apps/web/app/assets/empty-state/wiki/name-filter-light.svg diff --git a/apps/web/public/empty-state/wiki/navigation-pane/assets-dark.webp b/apps/web/app/assets/empty-state/wiki/navigation-pane/assets-dark.webp similarity index 100% rename from apps/web/public/empty-state/wiki/navigation-pane/assets-dark.webp rename to apps/web/app/assets/empty-state/wiki/navigation-pane/assets-dark.webp diff --git a/apps/web/public/empty-state/wiki/navigation-pane/assets-light.webp b/apps/web/app/assets/empty-state/wiki/navigation-pane/assets-light.webp similarity index 100% rename from apps/web/public/empty-state/wiki/navigation-pane/assets-light.webp rename to apps/web/app/assets/empty-state/wiki/navigation-pane/assets-light.webp diff --git a/apps/web/public/empty-state/wiki/navigation-pane/outline-dark.webp b/apps/web/app/assets/empty-state/wiki/navigation-pane/outline-dark.webp similarity index 100% rename from apps/web/public/empty-state/wiki/navigation-pane/outline-dark.webp rename to apps/web/app/assets/empty-state/wiki/navigation-pane/outline-dark.webp diff --git a/apps/web/public/empty-state/wiki/navigation-pane/outline-light.webp b/apps/web/app/assets/empty-state/wiki/navigation-pane/outline-light.webp similarity index 100% rename from apps/web/public/empty-state/wiki/navigation-pane/outline-light.webp rename to apps/web/app/assets/empty-state/wiki/navigation-pane/outline-light.webp diff --git a/apps/web/public/empty-state/wiki/private-dark.webp b/apps/web/app/assets/empty-state/wiki/private-dark.webp similarity index 100% rename from apps/web/public/empty-state/wiki/private-dark.webp rename to apps/web/app/assets/empty-state/wiki/private-dark.webp diff --git a/apps/web/public/empty-state/wiki/private-light.webp b/apps/web/app/assets/empty-state/wiki/private-light.webp similarity index 100% rename from apps/web/public/empty-state/wiki/private-light.webp rename to apps/web/app/assets/empty-state/wiki/private-light.webp diff --git a/apps/web/public/empty-state/wiki/public-dark.webp b/apps/web/app/assets/empty-state/wiki/public-dark.webp similarity index 100% rename from apps/web/public/empty-state/wiki/public-dark.webp rename to apps/web/app/assets/empty-state/wiki/public-dark.webp diff --git a/apps/web/public/empty-state/wiki/public-light.webp b/apps/web/app/assets/empty-state/wiki/public-light.webp similarity index 100% rename from apps/web/public/empty-state/wiki/public-light.webp rename to apps/web/app/assets/empty-state/wiki/public-light.webp diff --git a/apps/web/public/empty-state/workspace-draft/issue-dark.webp b/apps/web/app/assets/empty-state/workspace-draft/issue-dark.webp similarity index 100% rename from apps/web/public/empty-state/workspace-draft/issue-dark.webp rename to apps/web/app/assets/empty-state/workspace-draft/issue-dark.webp diff --git a/apps/web/public/empty-state/workspace-draft/issue-light.webp b/apps/web/app/assets/empty-state/workspace-draft/issue-light.webp similarity index 100% rename from apps/web/public/empty-state/workspace-draft/issue-light.webp rename to apps/web/app/assets/empty-state/workspace-draft/issue-light.webp diff --git a/apps/web/public/empty-state/workspace-settings/api-tokens-dark-resp.webp b/apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/api-tokens-dark-resp.webp rename to apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark-resp.webp diff --git a/apps/web/public/empty-state/workspace-settings/api-tokens-dark.webp b/apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/api-tokens-dark.webp rename to apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark.webp diff --git a/apps/web/public/empty-state/workspace-settings/api-tokens-light-resp.webp b/apps/web/app/assets/empty-state/workspace-settings/api-tokens-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/api-tokens-light-resp.webp rename to apps/web/app/assets/empty-state/workspace-settings/api-tokens-light-resp.webp diff --git a/apps/web/public/empty-state/workspace-settings/api-tokens-light.webp b/apps/web/app/assets/empty-state/workspace-settings/api-tokens-light.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/api-tokens-light.webp rename to apps/web/app/assets/empty-state/workspace-settings/api-tokens-light.webp diff --git a/apps/web/public/empty-state/workspace-settings/exports-dark-resp.webp b/apps/web/app/assets/empty-state/workspace-settings/exports-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/exports-dark-resp.webp rename to apps/web/app/assets/empty-state/workspace-settings/exports-dark-resp.webp diff --git a/apps/web/public/empty-state/workspace-settings/exports-dark.webp b/apps/web/app/assets/empty-state/workspace-settings/exports-dark.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/exports-dark.webp rename to apps/web/app/assets/empty-state/workspace-settings/exports-dark.webp diff --git a/apps/web/public/empty-state/workspace-settings/exports-light-resp.webp b/apps/web/app/assets/empty-state/workspace-settings/exports-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/exports-light-resp.webp rename to apps/web/app/assets/empty-state/workspace-settings/exports-light-resp.webp diff --git a/apps/web/public/empty-state/workspace-settings/exports-light.webp b/apps/web/app/assets/empty-state/workspace-settings/exports-light.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/exports-light.webp rename to apps/web/app/assets/empty-state/workspace-settings/exports-light.webp diff --git a/apps/web/public/empty-state/workspace-settings/imports-dark-resp.webp b/apps/web/app/assets/empty-state/workspace-settings/imports-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/imports-dark-resp.webp rename to apps/web/app/assets/empty-state/workspace-settings/imports-dark-resp.webp diff --git a/apps/web/public/empty-state/workspace-settings/imports-dark.webp b/apps/web/app/assets/empty-state/workspace-settings/imports-dark.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/imports-dark.webp rename to apps/web/app/assets/empty-state/workspace-settings/imports-dark.webp diff --git a/apps/web/public/empty-state/workspace-settings/imports-light-resp.webp b/apps/web/app/assets/empty-state/workspace-settings/imports-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/imports-light-resp.webp rename to apps/web/app/assets/empty-state/workspace-settings/imports-light-resp.webp diff --git a/apps/web/public/empty-state/workspace-settings/imports-light.webp b/apps/web/app/assets/empty-state/workspace-settings/imports-light.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/imports-light.webp rename to apps/web/app/assets/empty-state/workspace-settings/imports-light.webp diff --git a/apps/web/public/empty-state/workspace-settings/integrations-dark-resp.webp b/apps/web/app/assets/empty-state/workspace-settings/integrations-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/integrations-dark-resp.webp rename to apps/web/app/assets/empty-state/workspace-settings/integrations-dark-resp.webp diff --git a/apps/web/public/empty-state/workspace-settings/integrations-dark.webp b/apps/web/app/assets/empty-state/workspace-settings/integrations-dark.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/integrations-dark.webp rename to apps/web/app/assets/empty-state/workspace-settings/integrations-dark.webp diff --git a/apps/web/public/empty-state/workspace-settings/integrations-light-resp.webp b/apps/web/app/assets/empty-state/workspace-settings/integrations-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/integrations-light-resp.webp rename to apps/web/app/assets/empty-state/workspace-settings/integrations-light-resp.webp diff --git a/apps/web/public/empty-state/workspace-settings/integrations-light.webp b/apps/web/app/assets/empty-state/workspace-settings/integrations-light.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/integrations-light.webp rename to apps/web/app/assets/empty-state/workspace-settings/integrations-light.webp diff --git a/apps/web/public/empty-state/workspace-settings/webhooks-dark-resp.webp b/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark-resp.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/webhooks-dark-resp.webp rename to apps/web/app/assets/empty-state/workspace-settings/webhooks-dark-resp.webp diff --git a/apps/web/public/empty-state/workspace-settings/webhooks-dark.webp b/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/webhooks-dark.webp rename to apps/web/app/assets/empty-state/workspace-settings/webhooks-dark.webp diff --git a/apps/web/public/empty-state/workspace-settings/webhooks-light-resp.webp b/apps/web/app/assets/empty-state/workspace-settings/webhooks-light-resp.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/webhooks-light-resp.webp rename to apps/web/app/assets/empty-state/workspace-settings/webhooks-light-resp.webp diff --git a/apps/web/public/empty-state/workspace-settings/webhooks-light.webp b/apps/web/app/assets/empty-state/workspace-settings/webhooks-light.webp similarity index 100% rename from apps/web/public/empty-state/workspace-settings/webhooks-light.webp rename to apps/web/app/assets/empty-state/workspace-settings/webhooks-light.webp diff --git a/apps/web/public/favicon/apple-touch-icon.png b/apps/web/app/assets/favicon/apple-touch-icon.png similarity index 100% rename from apps/web/public/favicon/apple-touch-icon.png rename to apps/web/app/assets/favicon/apple-touch-icon.png diff --git a/apps/web/public/favicon/favicon-16x16.png b/apps/web/app/assets/favicon/favicon-16x16.png similarity index 100% rename from apps/web/public/favicon/favicon-16x16.png rename to apps/web/app/assets/favicon/favicon-16x16.png diff --git a/apps/web/public/favicon/favicon-32x32.png b/apps/web/app/assets/favicon/favicon-32x32.png similarity index 100% rename from apps/web/public/favicon/favicon-32x32.png rename to apps/web/app/assets/favicon/favicon-32x32.png diff --git a/apps/web/public/favicon/favicon.ico b/apps/web/app/assets/favicon/favicon.ico similarity index 100% rename from apps/web/public/favicon/favicon.ico rename to apps/web/app/assets/favicon/favicon.ico diff --git a/apps/web/public/fonts/inter/bold-italic.ttf b/apps/web/app/assets/fonts/inter/bold-italic.ttf similarity index 100% rename from apps/web/public/fonts/inter/bold-italic.ttf rename to apps/web/app/assets/fonts/inter/bold-italic.ttf diff --git a/apps/web/public/fonts/inter/bold.ttf b/apps/web/app/assets/fonts/inter/bold.ttf similarity index 100% rename from apps/web/public/fonts/inter/bold.ttf rename to apps/web/app/assets/fonts/inter/bold.ttf diff --git a/apps/web/public/fonts/inter/heavy-italic.ttf b/apps/web/app/assets/fonts/inter/heavy-italic.ttf similarity index 100% rename from apps/web/public/fonts/inter/heavy-italic.ttf rename to apps/web/app/assets/fonts/inter/heavy-italic.ttf diff --git a/apps/web/public/fonts/inter/heavy.ttf b/apps/web/app/assets/fonts/inter/heavy.ttf similarity index 100% rename from apps/web/public/fonts/inter/heavy.ttf rename to apps/web/app/assets/fonts/inter/heavy.ttf diff --git a/apps/web/public/fonts/inter/light-italic.ttf b/apps/web/app/assets/fonts/inter/light-italic.ttf similarity index 100% rename from apps/web/public/fonts/inter/light-italic.ttf rename to apps/web/app/assets/fonts/inter/light-italic.ttf diff --git a/apps/web/public/fonts/inter/light.ttf b/apps/web/app/assets/fonts/inter/light.ttf similarity index 100% rename from apps/web/public/fonts/inter/light.ttf rename to apps/web/app/assets/fonts/inter/light.ttf diff --git a/apps/web/public/fonts/inter/medium-italic.ttf b/apps/web/app/assets/fonts/inter/medium-italic.ttf similarity index 100% rename from apps/web/public/fonts/inter/medium-italic.ttf rename to apps/web/app/assets/fonts/inter/medium-italic.ttf diff --git a/apps/web/public/fonts/inter/medium.ttf b/apps/web/app/assets/fonts/inter/medium.ttf similarity index 100% rename from apps/web/public/fonts/inter/medium.ttf rename to apps/web/app/assets/fonts/inter/medium.ttf diff --git a/apps/web/public/fonts/inter/regular-italic.ttf b/apps/web/app/assets/fonts/inter/regular-italic.ttf similarity index 100% rename from apps/web/public/fonts/inter/regular-italic.ttf rename to apps/web/app/assets/fonts/inter/regular-italic.ttf diff --git a/apps/web/public/fonts/inter/regular.ttf b/apps/web/app/assets/fonts/inter/regular.ttf similarity index 100% rename from apps/web/public/fonts/inter/regular.ttf rename to apps/web/app/assets/fonts/inter/regular.ttf diff --git a/apps/web/public/fonts/inter/semibold-italic.ttf b/apps/web/app/assets/fonts/inter/semibold-italic.ttf similarity index 100% rename from apps/web/public/fonts/inter/semibold-italic.ttf rename to apps/web/app/assets/fonts/inter/semibold-italic.ttf diff --git a/apps/web/public/fonts/inter/semibold.ttf b/apps/web/app/assets/fonts/inter/semibold.ttf similarity index 100% rename from apps/web/public/fonts/inter/semibold.ttf rename to apps/web/app/assets/fonts/inter/semibold.ttf diff --git a/apps/web/public/fonts/inter/thin-italic.ttf b/apps/web/app/assets/fonts/inter/thin-italic.ttf similarity index 100% rename from apps/web/public/fonts/inter/thin-italic.ttf rename to apps/web/app/assets/fonts/inter/thin-italic.ttf diff --git a/apps/web/public/fonts/inter/thin.ttf b/apps/web/app/assets/fonts/inter/thin.ttf similarity index 100% rename from apps/web/public/fonts/inter/thin.ttf rename to apps/web/app/assets/fonts/inter/thin.ttf diff --git a/apps/web/public/fonts/inter/ultrabold-italic.ttf b/apps/web/app/assets/fonts/inter/ultrabold-italic.ttf similarity index 100% rename from apps/web/public/fonts/inter/ultrabold-italic.ttf rename to apps/web/app/assets/fonts/inter/ultrabold-italic.ttf diff --git a/apps/web/public/fonts/inter/ultrabold.ttf b/apps/web/app/assets/fonts/inter/ultrabold.ttf similarity index 100% rename from apps/web/public/fonts/inter/ultrabold.ttf rename to apps/web/app/assets/fonts/inter/ultrabold.ttf diff --git a/apps/web/public/fonts/inter/ultralight-italic.ttf b/apps/web/app/assets/fonts/inter/ultralight-italic.ttf similarity index 100% rename from apps/web/public/fonts/inter/ultralight-italic.ttf rename to apps/web/app/assets/fonts/inter/ultralight-italic.ttf diff --git a/apps/web/public/fonts/inter/ultralight.ttf b/apps/web/app/assets/fonts/inter/ultralight.ttf similarity index 100% rename from apps/web/public/fonts/inter/ultralight.ttf rename to apps/web/app/assets/fonts/inter/ultralight.ttf diff --git a/apps/web/public/icons/icon-180x180.png b/apps/web/app/assets/icons/icon-180x180.png similarity index 100% rename from apps/web/public/icons/icon-180x180.png rename to apps/web/app/assets/icons/icon-180x180.png diff --git a/apps/web/app/assets/icons/icon-512x512.png b/apps/web/app/assets/icons/icon-512x512.png new file mode 100644 index 0000000000..4c070d0790 Binary files /dev/null and b/apps/web/app/assets/icons/icon-512x512.png differ diff --git a/apps/web/public/images/logo-spinner-dark.gif b/apps/web/app/assets/images/logo-spinner-dark.gif similarity index 100% rename from apps/web/public/images/logo-spinner-dark.gif rename to apps/web/app/assets/images/logo-spinner-dark.gif diff --git a/apps/web/public/images/logo-spinner-light.gif b/apps/web/app/assets/images/logo-spinner-light.gif similarity index 100% rename from apps/web/public/images/logo-spinner-light.gif rename to apps/web/app/assets/images/logo-spinner-light.gif diff --git a/apps/web/public/instance-not-ready.webp b/apps/web/app/assets/instance-not-ready.webp similarity index 100% rename from apps/web/public/instance-not-ready.webp rename to apps/web/app/assets/instance-not-ready.webp diff --git a/apps/web/public/instance-setup-done.webp b/apps/web/app/assets/instance-setup-done.webp similarity index 100% rename from apps/web/public/instance-setup-done.webp rename to apps/web/app/assets/instance-setup-done.webp diff --git a/apps/web/public/instance/maintenance-mode-dark.svg b/apps/web/app/assets/instance/maintenance-mode-dark.svg similarity index 100% rename from apps/web/public/instance/maintenance-mode-dark.svg rename to apps/web/app/assets/instance/maintenance-mode-dark.svg diff --git a/apps/web/public/instance/maintenance-mode-light.svg b/apps/web/app/assets/instance/maintenance-mode-light.svg similarity index 100% rename from apps/web/public/instance/maintenance-mode-light.svg rename to apps/web/app/assets/instance/maintenance-mode-light.svg diff --git a/apps/web/app/assets/logos/gitea-logo.svg b/apps/web/app/assets/logos/gitea-logo.svg new file mode 100644 index 0000000000..43291345df --- /dev/null +++ b/apps/web/app/assets/logos/gitea-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/logos/github-black.png b/apps/web/app/assets/logos/github-black.png similarity index 100% rename from apps/web/public/logos/github-black.png rename to apps/web/app/assets/logos/github-black.png diff --git a/apps/web/public/logos/github-dark.svg b/apps/web/app/assets/logos/github-dark.svg similarity index 100% rename from apps/web/public/logos/github-dark.svg rename to apps/web/app/assets/logos/github-dark.svg diff --git a/apps/web/public/logos/github-square.png b/apps/web/app/assets/logos/github-square.png similarity index 100% rename from apps/web/public/logos/github-square.png rename to apps/web/app/assets/logos/github-square.png diff --git a/apps/web/public/logos/github-white.png b/apps/web/app/assets/logos/github-white.png similarity index 100% rename from apps/web/public/logos/github-white.png rename to apps/web/app/assets/logos/github-white.png diff --git a/apps/web/public/logos/gitlab-logo.svg b/apps/web/app/assets/logos/gitlab-logo.svg similarity index 100% rename from apps/web/public/logos/gitlab-logo.svg rename to apps/web/app/assets/logos/gitlab-logo.svg diff --git a/apps/web/public/logos/google-logo.svg b/apps/web/app/assets/logos/google-logo.svg similarity index 100% rename from apps/web/public/logos/google-logo.svg rename to apps/web/app/assets/logos/google-logo.svg diff --git a/apps/web/public/mac-command.svg b/apps/web/app/assets/mac-command.svg similarity index 100% rename from apps/web/public/mac-command.svg rename to apps/web/app/assets/mac-command.svg diff --git a/apps/web/public/og-image.png b/apps/web/app/assets/og-image.png similarity index 100% rename from apps/web/public/og-image.png rename to apps/web/app/assets/og-image.png diff --git a/apps/web/public/onboarding/cycles.webp b/apps/web/app/assets/onboarding/cycles.webp similarity index 100% rename from apps/web/public/onboarding/cycles.webp rename to apps/web/app/assets/onboarding/cycles.webp diff --git a/apps/web/public/onboarding/issues.webp b/apps/web/app/assets/onboarding/issues.webp similarity index 100% rename from apps/web/public/onboarding/issues.webp rename to apps/web/app/assets/onboarding/issues.webp diff --git a/apps/web/public/onboarding/modules.webp b/apps/web/app/assets/onboarding/modules.webp similarity index 100% rename from apps/web/public/onboarding/modules.webp rename to apps/web/app/assets/onboarding/modules.webp diff --git a/apps/web/public/onboarding/onboarding-pages.webp b/apps/web/app/assets/onboarding/onboarding-pages.webp similarity index 100% rename from apps/web/public/onboarding/onboarding-pages.webp rename to apps/web/app/assets/onboarding/onboarding-pages.webp diff --git a/apps/web/public/onboarding/pages.webp b/apps/web/app/assets/onboarding/pages.webp similarity index 100% rename from apps/web/public/onboarding/pages.webp rename to apps/web/app/assets/onboarding/pages.webp diff --git a/apps/web/public/onboarding/views.webp b/apps/web/app/assets/onboarding/views.webp similarity index 100% rename from apps/web/public/onboarding/views.webp rename to apps/web/app/assets/onboarding/views.webp diff --git a/apps/web/public/plane-logos/black-horizontal-with-blue-logo.png b/apps/web/app/assets/plane-logos/black-horizontal-with-blue-logo.png similarity index 100% rename from apps/web/public/plane-logos/black-horizontal-with-blue-logo.png rename to apps/web/app/assets/plane-logos/black-horizontal-with-blue-logo.png diff --git a/apps/web/public/plane-logos/blue-without-text.png b/apps/web/app/assets/plane-logos/blue-without-text.png similarity index 100% rename from apps/web/public/plane-logos/blue-without-text.png rename to apps/web/app/assets/plane-logos/blue-without-text.png diff --git a/apps/web/public/plane-logos/white-horizontal-with-blue-logo.png b/apps/web/app/assets/plane-logos/white-horizontal-with-blue-logo.png similarity index 100% rename from apps/web/public/plane-logos/white-horizontal-with-blue-logo.png rename to apps/web/app/assets/plane-logos/white-horizontal-with-blue-logo.png diff --git a/apps/web/public/plane-logos/white-horizontal.svg b/apps/web/app/assets/plane-logos/white-horizontal.svg similarity index 100% rename from apps/web/public/plane-logos/white-horizontal.svg rename to apps/web/app/assets/plane-logos/white-horizontal.svg diff --git a/apps/space/public/instance/plane-takeoff.png b/apps/web/app/assets/plane-takeoff.png similarity index 100% rename from apps/space/public/instance/plane-takeoff.png rename to apps/web/app/assets/plane-takeoff.png diff --git a/apps/web/public/services/csv.svg b/apps/web/app/assets/services/csv.svg similarity index 100% rename from apps/web/public/services/csv.svg rename to apps/web/app/assets/services/csv.svg diff --git a/apps/web/public/services/excel.svg b/apps/web/app/assets/services/excel.svg similarity index 100% rename from apps/web/public/services/excel.svg rename to apps/web/app/assets/services/excel.svg diff --git a/apps/web/public/services/github.png b/apps/web/app/assets/services/github.png similarity index 100% rename from apps/web/public/services/github.png rename to apps/web/app/assets/services/github.png diff --git a/apps/web/public/services/jira.svg b/apps/web/app/assets/services/jira.svg similarity index 100% rename from apps/web/public/services/jira.svg rename to apps/web/app/assets/services/jira.svg diff --git a/apps/web/public/services/json.svg b/apps/web/app/assets/services/json.svg similarity index 100% rename from apps/web/public/services/json.svg rename to apps/web/app/assets/services/json.svg diff --git a/apps/web/public/services/slack.png b/apps/web/app/assets/services/slack.png similarity index 100% rename from apps/web/public/services/slack.png rename to apps/web/app/assets/services/slack.png diff --git a/apps/web/public/user.png b/apps/web/app/assets/user.png similarity index 100% rename from apps/web/public/user.png rename to apps/web/app/assets/user.png diff --git a/apps/web/public/users/user-1.png b/apps/web/app/assets/users/user-1.png similarity index 100% rename from apps/web/public/users/user-1.png rename to apps/web/app/assets/users/user-1.png diff --git a/apps/web/public/users/user-2.png b/apps/web/app/assets/users/user-2.png similarity index 100% rename from apps/web/public/users/user-2.png rename to apps/web/app/assets/users/user-2.png diff --git a/apps/web/public/users/user-profile-cover-default-img.png b/apps/web/app/assets/users/user-profile-cover-default-img.png similarity index 100% rename from apps/web/public/users/user-profile-cover-default-img.png rename to apps/web/app/assets/users/user-profile-cover-default-img.png diff --git a/apps/web/public/workspace-active-cycles/cta-l-1-dark.webp b/apps/web/app/assets/workspace-active-cycles/cta-l-1-dark.webp similarity index 100% rename from apps/web/public/workspace-active-cycles/cta-l-1-dark.webp rename to apps/web/app/assets/workspace-active-cycles/cta-l-1-dark.webp diff --git a/apps/web/public/workspace-active-cycles/cta-l-1-light.webp b/apps/web/app/assets/workspace-active-cycles/cta-l-1-light.webp similarity index 100% rename from apps/web/public/workspace-active-cycles/cta-l-1-light.webp rename to apps/web/app/assets/workspace-active-cycles/cta-l-1-light.webp diff --git a/apps/web/public/workspace-active-cycles/cta-r-1-dark.webp b/apps/web/app/assets/workspace-active-cycles/cta-r-1-dark.webp similarity index 100% rename from apps/web/public/workspace-active-cycles/cta-r-1-dark.webp rename to apps/web/app/assets/workspace-active-cycles/cta-r-1-dark.webp diff --git a/apps/web/public/workspace-active-cycles/cta-r-1-light.webp b/apps/web/app/assets/workspace-active-cycles/cta-r-1-light.webp similarity index 100% rename from apps/web/public/workspace-active-cycles/cta-r-1-light.webp rename to apps/web/app/assets/workspace-active-cycles/cta-r-1-light.webp diff --git a/apps/web/public/workspace-active-cycles/cta-r-2-dark.webp b/apps/web/app/assets/workspace-active-cycles/cta-r-2-dark.webp similarity index 100% rename from apps/web/public/workspace-active-cycles/cta-r-2-dark.webp rename to apps/web/app/assets/workspace-active-cycles/cta-r-2-dark.webp diff --git a/apps/web/public/workspace-active-cycles/cta-r-2-light.webp b/apps/web/app/assets/workspace-active-cycles/cta-r-2-light.webp similarity index 100% rename from apps/web/public/workspace-active-cycles/cta-r-2-light.webp rename to apps/web/app/assets/workspace-active-cycles/cta-r-2-light.webp diff --git a/apps/web/public/workspace/workspace-creation-disabled.png b/apps/web/app/assets/workspace/workspace-creation-disabled.png similarity index 100% rename from apps/web/public/workspace/workspace-creation-disabled.png rename to apps/web/app/assets/workspace/workspace-creation-disabled.png diff --git a/apps/web/public/workspace/workspace-not-available.png b/apps/web/app/assets/workspace/workspace-not-available.png similarity index 100% rename from apps/web/public/workspace/workspace-not-available.png rename to apps/web/app/assets/workspace/workspace-not-available.png diff --git a/apps/web/app/compat/next/helper.ts b/apps/web/app/compat/next/helper.ts new file mode 100644 index 0000000000..fe1a984460 --- /dev/null +++ b/apps/web/app/compat/next/helper.ts @@ -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; + } +} diff --git a/apps/web/app/compat/next/image.tsx b/apps/web/app/compat/next/image.tsx new file mode 100644 index 0000000000..f9a858db2c --- /dev/null +++ b/apps/web/app/compat/next/image.tsx @@ -0,0 +1,33 @@ +"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 & { + src: string; + fill?: boolean; + priority?: boolean; + quality?: number; + placeholder?: "blur" | "empty"; + blurDataURL?: string; +}; + +const Image: React.FC = ({ + src, + alt = "", + fill, + priority: _priority, + quality: _quality, + placeholder: _placeholder, + blurDataURL: _blurDataURL, + ...rest +}) => { + // If fill is true, apply object-fit styles + const style = fill ? { objectFit: "cover" as const, width: "100%", height: "100%" } : rest.style; + + return {alt}; +}; + +export default Image; diff --git a/apps/web/app/compat/next/link.tsx b/apps/web/app/compat/next/link.tsx new file mode 100644 index 0000000000..4f42363272 --- /dev/null +++ b/apps/web/app/compat/next/link.tsx @@ -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 = ({ + href, + replace, + prefetch: _prefetch, + scroll: _scroll, + shallow: _shallow, + ...rest +}) => ; + +export default Link; diff --git a/apps/web/app/compat/next/navigation.ts b/apps/web/app/compat/next/navigation.ts new file mode 100644 index 0000000000..ecb076fed6 --- /dev/null +++ b/apps/web/app/compat/next/navigation.ts @@ -0,0 +1,48 @@ +"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) => { + // Defer navigation to avoid state updates during render + setTimeout(() => navigate(ensureTrailingSlash(to)), 0); + }, + replace: (to: string) => { + // Defer navigation to avoid state updates during render + setTimeout(() => navigate(ensureTrailingSlash(to), { replace: true }), 0); + }, + back: () => { + setTimeout(() => navigate(-1), 0); + }, + forward: () => { + setTimeout(() => navigate(1), 0); + }, + 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(); +} diff --git a/apps/web/app/compat/next/script.tsx b/apps/web/app/compat/next/script.tsx new file mode 100644 index 0000000000..2baa63f455 --- /dev/null +++ b/apps/web/app/compat/next/script.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect } from "react"; + +type ScriptProps = { + src?: string; + id?: string; + strategy?: "beforeInteractive" | "afterInteractive" | "lazyOnload" | "worker"; + onLoad?: () => void; + onError?: () => void; + children?: string; + defer?: boolean; + [key: string]: any; +}; + +// Minimal shim for next/script that creates a script tag +const Script: React.FC = ({ src, id, strategy: _strategy, onLoad, onError, children, ...rest }) => { + useEffect(() => { + if (src) { + const script = document.createElement("script"); + if (id) script.id = id; + script.src = src; + if (onLoad) script.onload = onLoad; + if (onError) script.onerror = onError; + Object.keys(rest).forEach((key) => { + script.setAttribute(key, rest[key]); + }); + document.body.appendChild(script); + + return () => { + if (script.parentNode) { + document.body.removeChild(script); + } + }; + } else if (children) { + const script = document.createElement("script"); + if (id) script.id = id; + script.textContent = children; + Object.keys(rest).forEach((key) => { + script.setAttribute(key, rest[key]); + }); + document.body.appendChild(script); + + return () => { + if (script.parentNode) { + document.body.removeChild(script); + } + }; + } + }, [src, id, children, onLoad, onError, rest]); + + return null; +}; + +export default Script; diff --git a/apps/web/app/error/dev.tsx b/apps/web/app/error/dev.tsx new file mode 100644 index 0000000000..d4c30165bc --- /dev/null +++ b/apps/web/app/error/dev.tsx @@ -0,0 +1,155 @@ +"use client"; + +// plane imports +import { isRouteErrorResponse } from "react-router"; +import { Banner } from "@plane/propel/banner"; +import { Button } from "@plane/propel/button"; +import { Card, ECardVariant } from "@plane/propel/card"; +import { InfoFillIcon } from "@plane/propel/icons"; + +interface ErrorActionsProps { + onGoHome: () => void; + onReload?: () => void; +} + +const ErrorActions: React.FC = ({ onGoHome, onReload }) => ( +
+ + {onReload && ( + + )} +
+); + +interface DevErrorComponentProps { + error: unknown; + onGoHome: () => void; + onReload: () => void; +} + +export const DevErrorComponent: React.FC = ({ error, onGoHome, onReload }) => { + if (isRouteErrorResponse(error)) { + return ( +
+
+ } + title="Route Error Response" + animationDuration={0} + /> + + +
+
+

+ {error.status} {error.statusText} +

+
+
+ +
+

Error Data

+
+

{error.data}

+
+
+ + +
+ +
+
+ ); + } + + if (error instanceof Error) { + return ( +
+
+ } + title="Runtime Error" + animationDuration={0} + /> + +
+
+

Error

+
+
+ +
+

Message

+
+

{error.message}

+
+
+ + {error.stack && ( +
+

Stack Trace

+
+
+                      {error.stack}
+                    
+
+
+ )} + + +
+ + + +
+ +
+

Development Mode

+

+ This detailed error view is only visible in development. In production, users will see a friendly + error page. +

+
+
+
+
+
+ ); + } + + return ( +
+
+ } + title="Unknown Error" + animationDuration={0} + /> + + +
+
+

Unknown Error

+
+
+ +
+

+ An unknown error occurred. Please try refreshing the page or contact support if the problem persists. +

+
+ + +
+ +
+
+ ); +}; diff --git a/apps/web/app/error/index.tsx b/apps/web/app/error/index.tsx new file mode 100644 index 0000000000..b577f866be --- /dev/null +++ b/apps/web/app/error/index.tsx @@ -0,0 +1,21 @@ +"use client"; + +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; +// layouts +import { DevErrorComponent } from "./dev"; +import { ProdErrorComponent } from "./prod"; + +export const CustomErrorComponent: React.FC<{ error: unknown }> = ({ error }) => { + // router + const router = useAppRouter(); + + const handleGoHome = () => router.push("/"); + const handleReload = () => window.location.reload(); + + if (import.meta.env.DEV) { + return ; + } + + return ; +}; diff --git a/apps/web/app/error.tsx b/apps/web/app/error/prod.tsx similarity index 83% rename from apps/web/app/error.tsx rename to apps/web/app/error/prod.tsx index a6fa660a7a..e30c58bddf 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error/prod.tsx @@ -1,14 +1,13 @@ "use client"; -import Image from "next/image"; import { useTheme } from "next-themes"; -// layouts +// plane imports import { Button } from "@plane/propel/button"; -import { useAppRouter } from "@/hooks/use-app-router"; +// assets +import maintenanceModeDarkModeImage from "@/app/assets/instance/maintenance-mode-dark.svg?url"; +import maintenanceModeLightModeImage from "@/app/assets/instance/maintenance-mode-light.svg?url"; +// layouts import DefaultLayout from "@/layouts/default-layout"; -// images -import maintenanceModeDarkModeImage from "@/public/instance/maintenance-mode-dark.svg"; -import maintenanceModeLightModeImage from "@/public/instance/maintenance-mode-light.svg"; const linkMap = [ { @@ -28,10 +27,14 @@ const linkMap = [ }, ]; -export default function CustomErrorComponent() { +// Production Error Component +interface ProdErrorComponentProps { + onGoHome: () => void; +} + +export const ProdErrorComponent: React.FC = ({ onGoHome }) => { // hooks const { resolvedTheme } = useTheme(); - const router = useAppRouter(); // derived values const maintenanceModeImage = resolvedTheme === "dark" ? maintenanceModeDarkModeImage : maintenanceModeLightModeImage; @@ -40,7 +43,7 @@ export default function CustomErrorComponent() {
-
-
@@ -83,4 +86,4 @@ export default function CustomErrorComponent() {
); -} +}; diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx deleted file mode 100644 index b0b191e411..0000000000 --- a/apps/web/app/global-error.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import NextError from "next/error"; - -export default function GlobalError() { - return ( - - - {/* `NextError` is the default Next.js error page component. Its type - definition requires a `statusCode` prop. However, since the App Router - does not expose status codes for errors, we simply pass 0 to render a - generic error message. */} - - - - ); -} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 433dea7f91..d73d82d0dd 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,4 +1,3 @@ -import { Metadata, Viewport } from "next"; import Script from "next/script"; // styles @@ -9,50 +8,46 @@ import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants"; // helpers import { cn } from "@plane/utils"; +// assets +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"; +import icon180 from "@/app/assets/icons/icon-180x180.png?url"; +import icon512 from "@/app/assets/icons/icon-512x512.png?url"; + // local import { AppProvider } from "./provider"; -export const metadata: Metadata = { - title: "Plane | Simple, extensible, open-source project management tool.", - description: SITE_DESCRIPTION, - metadataBase: new URL("https://app.plane.so"), - openGraph: { - title: "Plane | Simple, extensible, open-source project management tool.", - description: "Open-source project management tool to manage work items, cycles, and product roadmaps easily", - url: "https://app.plane.so/", - images: [ - { - url: "/og-image.png", - width: 1200, - height: 630, - alt: "Plane - Modern project management", - }, - ], +export const meta = () => [ + { title: "Plane | Simple, extensible, open-source project management tool." }, + { name: "description", content: SITE_DESCRIPTION }, + { + name: "keywords", + content: + "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", }, - keywords: - "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", - twitter: { - site: "@planepowers", - card: "summary_large_image", - images: [ - { - url: "/og-image.png", - width: 1200, - height: 630, - alt: "Plane - Modern project management", - }, - ], + { + name: "viewport", + content: + "width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover", }, -}; - -export const viewport: Viewport = { - minimumScale: 1, - initialScale: 1, - maximumScale: 1, - userScalable: false, - width: "device-width", - viewportFit: "cover", -}; + { property: "og:title", content: "Plane | Simple, extensible, open-source project management tool." }, + { + property: "og:description", + content: "Open-source project management tool to manage work items, cycles, and product roadmaps easily", + }, + { property: "og:url", content: "https://app.plane.so/" }, + { property: "og:image", content: "https://app.plane.so/og-image.png" }, + { property: "og:image:width", content: "1200" }, + { property: "og:image:height", content: "630" }, + { property: "og:image:alt", content: "Plane - Modern project management" }, + { name: "twitter:site", content: "@planepowers" }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:image", content: "https://app.plane.so/og-image.png" }, + { name: "twitter:image:width", content: "1200" }, + { name: "twitter:image:height", content: "630" }, + { name: "twitter:image:alt", content: "Plane - Modern project management" }, +]; export default function RootLayout({ children }: { children: React.ReactNode }) { const isSessionRecorderEnabled = parseInt(process.env.NEXT_PUBLIC_ENABLE_SESSION_RECORDER || "0"); @@ -61,10 +56,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - - + + - + {/* Meta info for PWA */} @@ -72,10 +67,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - - - - + + + diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index 268d4d7b64..e7a8178342 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -1,28 +1,25 @@ "use client"; -import React from "react"; -import { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; // ui import { Button } from "@plane/propel/button"; // images -import Image404 from "@/public/404.svg"; +import Image404 from "@/app/assets/404.svg?url"; +// types +import type { Route } from "./+types/not-found"; -export const metadata: Metadata = { - title: "404 - Page Not Found", - robots: { - index: false, - follow: false, - }, -}; +export const meta: Route.MetaFunction = () => [ + { title: "404 - Page Not Found" }, + { name: "robots", content: "noindex, nofollow" }, +]; const PageNotFound = () => (
- 404- Page not found + 404- Page not found

Oops! Something went wrong.

diff --git a/apps/web/app/provider.tsx b/apps/web/app/provider.tsx index 4237a23e18..125f502160 100644 --- a/apps/web/app/provider.tsx +++ b/apps/web/app/provider.tsx @@ -1,26 +1,27 @@ "use client"; -import { FC, ReactNode } from "react"; -import { AppProgressProvider as ProgressProvider } from "@bprogress/next"; -import dynamic from "next/dynamic"; +import type { FC, ReactNode } from "react"; +import { lazy, Suspense } from "react"; import { useTheme, ThemeProvider } from "next-themes"; import { SWRConfig } from "swr"; // Plane Imports import { WEB_SWR_CONFIG } from "@plane/constants"; import { TranslationProvider } from "@plane/i18n"; import { Toast } from "@plane/propel/toast"; -//helpers +// helpers import { resolveGeneralTheme } from "@plane/utils"; // polyfills import "@/lib/polyfills"; +// progress bar +import { AppProgressBar } from "@/lib/b-progress"; // mobx store provider import { StoreProvider } from "@/lib/store-context"; // wrappers import { InstanceWrapper } from "@/lib/wrappers/instance-wrapper"; -// dynamic imports -const StoreWrapper = dynamic(() => import("@/lib/wrappers/store-wrapper"), { ssr: false }); -const PostHogProvider = dynamic(() => import("@/lib/posthog-provider"), { ssr: false }); -const IntercomProvider = dynamic(() => import("@/lib/intercom-provider"), { ssr: false }); +// lazy imports +const StoreWrapper = lazy(() => import("@/lib/wrappers/store-wrapper")); +const PostHogProvider = lazy(() => import("@/lib/posthog-provider")); +const IntercomProvider = lazy(() => import("@/lib/intercom-provider")); export interface IAppProvider { children: ReactNode; @@ -35,30 +36,24 @@ export const AppProvider: FC = (props) => { const { children } = props; // themes return ( - <> - - - - - - - - - - {children} - - - - - - - - - + + + + + + + + + + + {children} + + + + + + + + ); }; diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx new file mode 100644 index 0000000000..c37fe6f60e --- /dev/null +++ b/apps/web/app/root.tsx @@ -0,0 +1,126 @@ +import type { ReactNode } from "react"; +import Script from "next/script"; +import { Links, Meta, Outlet, Scripts } from "react-router"; +import type { LinksFunction } from "react-router"; +// plane imports +import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants"; +import { cn } from "@plane/utils"; +// types +// assets +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"; +import icon180 from "@/app/assets/icons/icon-180x180.png?url"; +import icon512 from "@/app/assets/icons/icon-512x512.png?url"; +import ogImage from "@/app/assets/og-image.png?url"; +import globalStyles from "@/styles/globals.css?url"; +import type { Route } from "./+types/root"; +// local +import { CustomErrorComponent } from "./error"; +import { AppProvider } from "./provider"; + +const APP_TITLE = "Plane | Simple, extensible, open-source project management tool."; + +export const links: LinksFunction = () => [ + { 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" }, + { rel: "apple-touch-icon", href: icon512 }, + { rel: "apple-touch-icon", sizes: "180x180", href: icon180 }, + { rel: "apple-touch-icon", sizes: "512x512", href: icon512 }, + { rel: "manifest", href: "/manifest.json" }, + { rel: "stylesheet", href: globalStyles }, +]; + +export function Layout({ children }: { children: ReactNode }) { + const isSessionRecorderEnabled = parseInt(process.env.NEXT_PUBLIC_ENABLE_SESSION_RECORDER || "0"); + + return ( + + + + + + {/* Meta info for PWA */} + + + + + + + + + + +
+
+ +
+
{children}
+
+
+ + {process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && ( + + )} + + + ); +} + +export const meta: Route.MetaFunction = () => [ + { title: APP_TITLE }, + { name: "description", content: SITE_DESCRIPTION }, + { property: "og:title", content: APP_TITLE }, + { + property: "og:description", + content: "Open-source project management tool to manage work items, cycles, and product roadmaps easily", + }, + { property: "og:url", content: "https://app.plane.so/" }, + { property: "og:image", content: ogImage }, + { property: "og:image:width", content: "1200" }, + { property: "og:image:height", content: "630" }, + { property: "og:image:alt", content: "Plane - Modern project management" }, + { + name: "keywords", + content: + "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", + }, + { name: "twitter:site", content: "@planepowers" }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:image", content: ogImage }, + { name: "twitter:image:width", content: "1200" }, + { name: "twitter:image:height", content: "630" }, + { name: "twitter:image:alt", content: "Plane - Modern project management" }, +]; + +export default function Root() { + return ; +} + +export function HydrateFallback() { + return null; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + return ; +} diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts new file mode 100644 index 0000000000..2589937839 --- /dev/null +++ b/apps/web/app/routes.ts @@ -0,0 +1,84 @@ +import { route } from "@react-router/dev/routes"; +import type { RouteConfigEntry } from "@react-router/dev/routes"; +import { coreRoutes } from "./routes/core"; +import { extendedRoutes } from "./routes/extended"; + +/** + * Merges two route configurations intelligently. + * - Deep merges children when the same layout file exists in both arrays + * - Deduplicates routes by file property, preferring extended over core + * - Maintains order: core routes first, then extended routes at each level + */ +function mergeRoutes(core: RouteConfigEntry[], extended: RouteConfigEntry[]): RouteConfigEntry[] { + // Step 1: Create a Map to track routes by file path + const routeMap = new Map(); + + // Step 2: Process core routes first + for (const coreRoute of core) { + const fileKey = coreRoute.file; + routeMap.set(fileKey, coreRoute); + } + + // Step 3: Process extended routes + for (const extendedRoute of extended) { + const fileKey = extendedRoute.file; + + if (routeMap.has(fileKey)) { + // Route exists in both - need to merge + const coreRoute = routeMap.get(fileKey)!; + + // Check if both have children (layouts that need deep merging) + if (coreRoute.children && extendedRoute.children) { + // Deep merge: recursively merge children + const mergedChildren = mergeRoutes( + Array.isArray(coreRoute.children) ? coreRoute.children : [], + Array.isArray(extendedRoute.children) ? extendedRoute.children : [] + ); + routeMap.set(fileKey, { + ...extendedRoute, + children: mergedChildren, + }); + } else { + // No children or only one has children - prefer extended + routeMap.set(fileKey, extendedRoute); + } + } else { + // Route only exists in extended + routeMap.set(fileKey, extendedRoute); + } + } + + // Step 4: Build final array maintaining order (core first, then extended-only) + const result: RouteConfigEntry[] = []; + + // Add all core routes (now merged or original) + for (const coreRoute of core) { + const fileKey = coreRoute.file; + if (routeMap.has(fileKey)) { + result.push(routeMap.get(fileKey)!); + routeMap.delete(fileKey); // Remove so we don't add it again + } + } + + // Add remaining extended-only routes + for (const extendedRoute of extended) { + const fileKey = extendedRoute.file; + if (routeMap.has(fileKey)) { + result.push(routeMap.get(fileKey)!); + routeMap.delete(fileKey); + } + } + + return result; +} + +/** + * Main Routes Configuration + * This file serves as the entry point for the route configuration. + */ +const mergedRoutes: RouteConfigEntry[] = mergeRoutes(coreRoutes, extendedRoutes); + +// Add catch-all route at the end (404 handler) +const routes: RouteConfigEntry[] = [...mergedRoutes, route("*", "./not-found.tsx")]; + +export default routes; diff --git a/apps/web/app/routes/core.ts b/apps/web/app/routes/core.ts new file mode 100644 index 0000000000..e502a4e68f --- /dev/null +++ b/apps/web/app/routes/core.ts @@ -0,0 +1,429 @@ +import { index, layout, route } from "@react-router/dev/routes"; +import type { RouteConfig, RouteConfigEntry } from "@react-router/dev/routes"; + +export const coreRoutes: RouteConfigEntry[] = [ + // ======================================================================== + // USER MANAGEMENT ROUTES + // ======================================================================== + + // Home - Sign In + layout("./(home)/layout.tsx", [index("./(home)/page.tsx")]), + + // Sign Up + layout("./(all)/sign-up/layout.tsx", [route("sign-up", "./(all)/sign-up/page.tsx")]), + + // Account Routes - Password Management + layout("./(all)/accounts/forgot-password/layout.tsx", [ + route("accounts/forgot-password", "./(all)/accounts/forgot-password/page.tsx"), + ]), + layout("./(all)/accounts/reset-password/layout.tsx", [ + route("accounts/reset-password", "./(all)/accounts/reset-password/page.tsx"), + ]), + layout("./(all)/accounts/set-password/layout.tsx", [ + route("accounts/set-password", "./(all)/accounts/set-password/page.tsx"), + ]), + + // Create Workspace + layout("./(all)/create-workspace/layout.tsx", [route("create-workspace", "./(all)/create-workspace/page.tsx")]), + + // Onboarding + layout("./(all)/onboarding/layout.tsx", [route("onboarding", "./(all)/onboarding/page.tsx")]), + + // Invitations + layout("./(all)/invitations/layout.tsx", [route("invitations", "./(all)/invitations/page.tsx")]), + + // Workspace Invitations + layout("./(all)/workspace-invitations/layout.tsx", [ + route("workspace-invitations", "./(all)/workspace-invitations/page.tsx"), + ]), + + // ======================================================================== + // ALL APP ROUTES + // ======================================================================== + layout("./(all)/layout.tsx", [ + // ====================================================================== + // WORKSPACE-SCOPED ROUTES + // ====================================================================== + layout("./(all)/[workspaceSlug]/layout.tsx", [ + // ==================================================================== + // PROJECTS APP SECTION - WORKSPACE LEVEL ROUTES + // ==================================================================== + layout("./(all)/[workspaceSlug]/(projects)/layout.tsx", [ + // -------------------------------------------------------------------- + // WORKSPACE LEVEL ROUTES + // -------------------------------------------------------------------- + + // Workspace Home + route(":workspaceSlug", "./(all)/[workspaceSlug]/(projects)/page.tsx"), + + // Active Cycles + layout("./(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx", [ + route(":workspaceSlug/active-cycles", "./(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx"), + ]), + + // Analytics + layout("./(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx", [ + route(":workspaceSlug/analytics/:tabId", "./(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx"), + ]), + + // Browse + layout("./(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx", [ + route(":workspaceSlug/browse/:workItem", "./(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx"), + ]), + + // Drafts + layout("./(all)/[workspaceSlug]/(projects)/drafts/layout.tsx", [ + route(":workspaceSlug/drafts", "./(all)/[workspaceSlug]/(projects)/drafts/page.tsx"), + ]), + + // Notifications + layout("./(all)/[workspaceSlug]/(projects)/notifications/layout.tsx", [ + route(":workspaceSlug/notifications", "./(all)/[workspaceSlug]/(projects)/notifications/page.tsx"), + ]), + + // Profile + layout("./(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx", [ + route(":workspaceSlug/profile/:userId", "./(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx"), + route( + ":workspaceSlug/profile/:userId/:profileViewId", + "./(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx" + ), + route( + ":workspaceSlug/profile/:userId/activity", + "./(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx" + ), + ]), + + // Stickies + layout("./(all)/[workspaceSlug]/(projects)/stickies/layout.tsx", [ + route(":workspaceSlug/stickies", "./(all)/[workspaceSlug]/(projects)/stickies/page.tsx"), + ]), + + // Workspace Views + layout("./(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx", [ + route(":workspaceSlug/workspace-views", "./(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx"), + route( + ":workspaceSlug/workspace-views/:globalViewId", + "./(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx" + ), + ]), + + // ==================================================================== + // PROJECT LEVEL ROUTES + // ==================================================================== + + // -------------------------------------------------------------------- + // PROJECT LEVEL ROUTES + // -------------------------------------------------------------------- + + // Project List + layout("./(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx", [ + route(":workspaceSlug/projects", "./(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx"), + ]), + + // Project Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx", [ + // Archived Projects + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [ + route( + ":workspaceSlug/projects/archives", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx" + ), + ]), + + // Project Issues + // Issues List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/issues", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx" + ), + ]), + + // Issue Detail + route( + ":workspaceSlug/projects/:projectId/issues/:issueId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx" + ), + + // Project Cycles + // Cycles List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/cycles", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx" + ), + ]), + + // Cycle Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/cycles/:cycleId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx" + ), + ]), + + // Project Modules + // Modules List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/modules", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx" + ), + ]), + + // Module Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/modules/:moduleId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx" + ), + ]), + + // Project Views + // Views List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/views", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx" + ), + ]), + + // View Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/views/:viewId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx" + ), + ]), + + // Project Pages + // Pages List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/pages", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx" + ), + ]), + + // Page Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/pages/:pageId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx" + ), + ]), + + // Project Intake + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/intake", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx" + ), + ]), + + // Project Archives - Issues, Cycles, Modules + // Project Archives - Issues - List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/archives/issues", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx" + ), + ]), + + // Project Archives - Issues - Detail + layout( + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx", + [ + route( + ":workspaceSlug/projects/:projectId/archives/issues/:archivedIssueId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx" + ), + ] + ), + + // Project Archives - Cycles + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/archives/cycles", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx" + ), + ]), + + // Project Archives - Modules + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/archives/modules", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx" + ), + ]), + ]), + ]), + + // ==================================================================== + // SETTINGS SECTION + // ==================================================================== + layout("./(all)/[workspaceSlug]/(settings)/layout.tsx", [ + // -------------------------------------------------------------------- + // WORKSPACE SETTINGS + // -------------------------------------------------------------------- + + layout("./(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx", [ + route(":workspaceSlug/settings", "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx"), + route( + ":workspaceSlug/settings/members", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx" + ), + route( + ":workspaceSlug/settings/billing", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx" + ), + route( + ":workspaceSlug/settings/integrations", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx" + ), + route( + ":workspaceSlug/settings/imports", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx" + ), + route( + ":workspaceSlug/settings/exports", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx" + ), + route( + ":workspaceSlug/settings/webhooks", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx" + ), + route( + ":workspaceSlug/settings/webhooks/:webhookId", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx" + ), + ]), + + // -------------------------------------------------------------------- + // ACCOUNT SETTINGS + // -------------------------------------------------------------------- + + layout("./(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx", [ + route(":workspaceSlug/settings/account", "./(all)/[workspaceSlug]/(settings)/settings/account/page.tsx"), + route( + ":workspaceSlug/settings/account/activity", + "./(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx" + ), + route( + ":workspaceSlug/settings/account/preferences", + "./(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx" + ), + route( + ":workspaceSlug/settings/account/notifications", + "./(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx" + ), + route( + ":workspaceSlug/settings/account/security", + "./(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx" + ), + route( + ":workspaceSlug/settings/account/api-tokens", + "./(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx" + ), + ]), + + // -------------------------------------------------------------------- + // PROJECT SETTINGS + // -------------------------------------------------------------------- + + layout("./(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx", [ + // CORE Routes + // Project Settings + route(":workspaceSlug/settings/projects", "./(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx"), + route( + ":workspaceSlug/settings/projects/:projectId", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx" + ), + // Project Members + route( + ":workspaceSlug/settings/projects/:projectId/members", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx" + ), + // Project Features + route( + ":workspaceSlug/settings/projects/:projectId/features", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx" + ), + // Project States + route( + ":workspaceSlug/settings/projects/:projectId/states", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx" + ), + // Project Labels + route( + ":workspaceSlug/settings/projects/:projectId/labels", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx" + ), + // Project Estimates + route( + ":workspaceSlug/settings/projects/:projectId/estimates", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx" + ), + // Project Automations + layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx", [ + route( + ":workspaceSlug/settings/projects/:projectId/automations", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx" + ), + ]), + ]), + ]), + ]), + // ====================================================================== + // STANDALONE ROUTES (outside workspace context) + // ====================================================================== + + // -------------------------------------------------------------------- + // PROFILE SETTINGS + // -------------------------------------------------------------------- + + layout("./(all)/profile/layout.tsx", [ + route("profile", "./(all)/profile/page.tsx"), + route("profile/activity", "./(all)/profile/activity/page.tsx"), + route("profile/appearance", "./(all)/profile/appearance/page.tsx"), + route("profile/notifications", "./(all)/profile/notifications/page.tsx"), + route("profile/security", "./(all)/profile/security/page.tsx"), + ]), + ]), + + // ======================================================================== + // REDIRECT ROUTES + // ======================================================================== + // Legacy URL redirects for backward compatibility + + // -------------------------------------------------------------------- + // REDIRECT ROUTES + // -------------------------------------------------------------------- + + // Project settings redirect: /:workspaceSlug/projects/:projectId/settings/:path* + // → /:workspaceSlug/settings/projects/:projectId/:path* + route(":workspaceSlug/projects/:projectId/settings/*", "routes/redirects/core/project-settings.tsx"), + + // Analytics redirect: /:workspaceSlug/analytics → /:workspaceSlug/analytics/overview + route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"), + + // API tokens redirect: /:workspaceSlug/settings/api-tokens + // → /:workspaceSlug/settings/account/api-tokens + route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"), + + // Inbox redirect: /:workspaceSlug/projects/:projectId/inbox + // → /:workspaceSlug/projects/:projectId/intake + route(":workspaceSlug/projects/:projectId/inbox", "routes/redirects/core/inbox.tsx"), + + // Sign-up redirects + route("accounts/sign-up", "routes/redirects/core/accounts-signup.tsx"), + + // Sign-in redirects (all redirect to home page) + route("sign-in", "routes/redirects/core/sign-in.tsx"), + route("signin", "routes/redirects/core/signin.tsx"), + route("login", "routes/redirects/core/login.tsx"), + + // Register redirect + route("register", "routes/redirects/core/register.tsx"), +] satisfies RouteConfig; diff --git a/apps/web/app/routes/extended.ts b/apps/web/app/routes/extended.ts new file mode 100644 index 0000000000..bbc5aa4cc0 --- /dev/null +++ b/apps/web/app/routes/extended.ts @@ -0,0 +1,3 @@ +import type { RouteConfigEntry } from "@react-router/dev/routes"; + +export const extendedRoutes: RouteConfigEntry[] = []; diff --git a/apps/web/app/routes/redirects/core/accounts-signup.tsx b/apps/web/app/routes/redirects/core/accounts-signup.tsx new file mode 100644 index 0000000000..5343e27bea --- /dev/null +++ b/apps/web/app/routes/redirects/core/accounts-signup.tsx @@ -0,0 +1,9 @@ +import { redirect } from "react-router"; + +export const clientLoader = () => { + throw redirect("/sign-up/"); +}; + +export default function AccountsSignup() { + return null; +} diff --git a/apps/web/app/routes/redirects/core/analytics.tsx b/apps/web/app/routes/redirects/core/analytics.tsx new file mode 100644 index 0000000000..21bacf5097 --- /dev/null +++ b/apps/web/app/routes/redirects/core/analytics.tsx @@ -0,0 +1,11 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/analytics"; + +export const clientLoader = ({ params }: Route.ClientLoaderArgs) => { + const { workspaceSlug } = params; + throw redirect(`/${workspaceSlug}/analytics/overview/`); +}; + +export default function Analytics() { + return null; +} diff --git a/apps/web/app/routes/redirects/core/api-tokens.tsx b/apps/web/app/routes/redirects/core/api-tokens.tsx new file mode 100644 index 0000000000..68007aa416 --- /dev/null +++ b/apps/web/app/routes/redirects/core/api-tokens.tsx @@ -0,0 +1,11 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/api-tokens"; + +export const clientLoader = ({ params }: Route.ClientLoaderArgs) => { + const { workspaceSlug } = params; + throw redirect(`/${workspaceSlug}/settings/account/api-tokens/`); +}; + +export default function ApiTokens() { + return null; +} diff --git a/apps/web/app/routes/redirects/core/inbox.tsx b/apps/web/app/routes/redirects/core/inbox.tsx new file mode 100644 index 0000000000..bf2bd9a503 --- /dev/null +++ b/apps/web/app/routes/redirects/core/inbox.tsx @@ -0,0 +1,11 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/inbox"; + +export const clientLoader = ({ params }: Route.ClientLoaderArgs) => { + const { workspaceSlug, projectId } = params; + throw redirect(`/${workspaceSlug}/projects/${projectId}/intake/`); +}; + +export default function Inbox() { + return null; +} diff --git a/apps/web/app/routes/redirects/core/index.ts b/apps/web/app/routes/redirects/core/index.ts new file mode 100644 index 0000000000..efd3ae40f8 --- /dev/null +++ b/apps/web/app/routes/redirects/core/index.ts @@ -0,0 +1,38 @@ +import { route } from "@react-router/dev/routes"; +import type { RouteConfigEntry } from "@react-router/dev/routes"; + +export const coreRedirectRoutes: RouteConfigEntry[] = [ + // ======================================================================== + // WORKSPACE & PROJECT REDIRECTS + // ======================================================================== + + // Project settings redirect: /:workspaceSlug/projects/:projectId/settings/:path* + // → /:workspaceSlug/settings/projects/:projectId/:path* + route(":workspaceSlug/projects/:projectId/settings/*", "routes/redirects/core/project-settings.tsx"), + + // Analytics redirect: /:workspaceSlug/analytics → /:workspaceSlug/analytics/overview + route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"), + + // API tokens redirect: /:workspaceSlug/settings/api-tokens + // → /:workspaceSlug/settings/account/api-tokens + route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"), + + // Inbox redirect: /:workspaceSlug/projects/:projectId/inbox + // → /:workspaceSlug/projects/:projectId/intake + route(":workspaceSlug/projects/:projectId/inbox", "routes/redirects/core/inbox.tsx"), + + // ======================================================================== + // AUTHENTICATION REDIRECTS + // ======================================================================== + + // Sign-up redirects + route("accounts/sign-up", "routes/redirects/core/accounts-signup.tsx"), + + // Sign-in redirects (all redirect to home page) + route("sign-in", "routes/redirects/core/sign-in.tsx"), + route("signin", "routes/redirects/core/signin.tsx"), + route("login", "routes/redirects/core/login.tsx"), + + // Register redirect + route("register", "routes/redirects/core/register.tsx"), +]; diff --git a/apps/web/app/routes/redirects/core/login.tsx b/apps/web/app/routes/redirects/core/login.tsx new file mode 100644 index 0000000000..ed49c8ca3e --- /dev/null +++ b/apps/web/app/routes/redirects/core/login.tsx @@ -0,0 +1,9 @@ +import { redirect } from "react-router"; + +export const clientLoader = () => { + throw redirect("/"); +}; + +export default function Login() { + return null; +} diff --git a/apps/web/app/routes/redirects/core/project-settings.tsx b/apps/web/app/routes/redirects/core/project-settings.tsx new file mode 100644 index 0000000000..da14c515a9 --- /dev/null +++ b/apps/web/app/routes/redirects/core/project-settings.tsx @@ -0,0 +1,13 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/project-settings"; + +export const clientLoader = ({ params }: Route.ClientLoaderArgs) => { + const { workspaceSlug, projectId } = params; + const splat = params["*"] || ""; + const destination = `/${workspaceSlug}/settings/projects/${projectId}${splat ? `/${splat}` : ""}/`; + throw redirect(destination); +}; + +export default function ProjectSettings() { + return null; +} diff --git a/apps/web/app/routes/redirects/core/register.tsx b/apps/web/app/routes/redirects/core/register.tsx new file mode 100644 index 0000000000..7910404957 --- /dev/null +++ b/apps/web/app/routes/redirects/core/register.tsx @@ -0,0 +1,9 @@ +import { redirect } from "react-router"; + +export const clientLoader = () => { + throw redirect("/sign-up/"); +}; + +export default function Register() { + return null; +} diff --git a/apps/web/app/routes/redirects/core/sign-in.tsx b/apps/web/app/routes/redirects/core/sign-in.tsx new file mode 100644 index 0000000000..83a91a3eb4 --- /dev/null +++ b/apps/web/app/routes/redirects/core/sign-in.tsx @@ -0,0 +1,9 @@ +import { redirect } from "react-router"; + +export const clientLoader = () => { + throw redirect("/"); +}; + +export default function SignIn() { + return null; +} diff --git a/apps/web/app/routes/redirects/core/signin.tsx b/apps/web/app/routes/redirects/core/signin.tsx new file mode 100644 index 0000000000..e440e83996 --- /dev/null +++ b/apps/web/app/routes/redirects/core/signin.tsx @@ -0,0 +1,9 @@ +import { redirect } from "react-router"; + +export const clientLoader = () => { + throw redirect("/"); +}; + +export default function Signin() { + return null; +} diff --git a/apps/web/app/routes/redirects/extended/index.ts b/apps/web/app/routes/redirects/extended/index.ts new file mode 100644 index 0000000000..7f2c496e15 --- /dev/null +++ b/apps/web/app/routes/redirects/extended/index.ts @@ -0,0 +1,3 @@ +import type { RouteConfigEntry } from "@react-router/dev/routes"; + +export const extendedRedirectRoutes: RouteConfigEntry[] = []; diff --git a/apps/web/app/routes/redirects/index.ts b/apps/web/app/routes/redirects/index.ts new file mode 100644 index 0000000000..4c78ea3545 --- /dev/null +++ b/apps/web/app/routes/redirects/index.ts @@ -0,0 +1,10 @@ +import type { RouteConfigEntry } from "@react-router/dev/routes"; +import { coreRedirectRoutes } from "./core"; +import { extendedRedirectRoutes } from "./extended"; + +/** + * REDIRECT ROUTES + * Centralized configuration for all route redirects + * Migrated from Next.js next.config.js redirects + */ +export const redirectRoutes: RouteConfigEntry[] = [...coreRedirectRoutes, ...extendedRedirectRoutes]; diff --git a/apps/web/app/types/next-image.d.ts b/apps/web/app/types/next-image.d.ts new file mode 100644 index 0000000000..d3f520b7ef --- /dev/null +++ b/apps/web/app/types/next-image.d.ts @@ -0,0 +1,12 @@ +declare module "next/image" { + type Props = React.ComponentProps<"img"> & { + src: string; + fill?: boolean; + priority?: boolean; + quality?: number; + placeholder?: "blur" | "empty"; + blurDataURL?: string; + }; + const Image: React.FC; + export default Image; +} diff --git a/apps/web/app/types/next-link.d.ts b/apps/web/app/types/next-link.d.ts new file mode 100644 index 0000000000..c724e3aec1 --- /dev/null +++ b/apps/web/app/types/next-link.d.ts @@ -0,0 +1,12 @@ +declare module "next/link" { + type Props = React.ComponentProps<"a"> & { + href: string; + replace?: boolean; + prefetch?: boolean; + scroll?: boolean; + shallow?: boolean; + }; + + const Link: React.FC; + export default Link; +} diff --git a/apps/web/app/types/next-navigation.d.ts b/apps/web/app/types/next-navigation.d.ts new file mode 100644 index 0000000000..67a80c4fae --- /dev/null +++ b/apps/web/app/types/next-navigation.d.ts @@ -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; + }; + + export function usePathname(): string; + export function useSearchParams(): URLSearchParams; + export function useParams>(): T; +} diff --git a/apps/web/app/types/next-script.d.ts b/apps/web/app/types/next-script.d.ts new file mode 100644 index 0000000000..299b5ed4c5 --- /dev/null +++ b/apps/web/app/types/next-script.d.ts @@ -0,0 +1,15 @@ +declare module "next/script" { + type ScriptProps = { + src?: string; + id?: string; + strategy?: "beforeInteractive" | "afterInteractive" | "lazyOnload" | "worker"; + onLoad?: () => void; + onError?: () => void; + children?: string; + defer?: boolean; + [key: string]: any; + }; + + const Script: React.FC; + export default Script; +} diff --git a/apps/web/app/types/react-router-virtual.d.ts b/apps/web/app/types/react-router-virtual.d.ts new file mode 100644 index 0000000000..abf3b638e4 --- /dev/null +++ b/apps/web/app/types/react-router-virtual.d.ts @@ -0,0 +1,5 @@ +declare module "virtual:react-router/server-build" { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const build: any; + export default build; +} diff --git a/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx b/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx index 1ce060b018..0f9c03b391 100644 --- a/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx +++ b/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx @@ -10,6 +10,13 @@ import { useTranslation } from "@plane/i18n"; import { getButtonStyling } from "@plane/propel/button"; import { ContentWrapper } from "@plane/ui"; import { cn } from "@plane/utils"; +// assets +import ctaL1Dark from "@/app/assets/workspace-active-cycles/cta-l-1-dark.webp?url"; +import ctaL1Light from "@/app/assets/workspace-active-cycles/cta-l-1-light.webp?url"; +import ctaR1Dark from "@/app/assets/workspace-active-cycles/cta-r-1-dark.webp?url"; +import ctaR1Light from "@/app/assets/workspace-active-cycles/cta-r-1-light.webp?url"; +import ctaR2Dark from "@/app/assets/workspace-active-cycles/cta-r-2-dark.webp?url"; +import ctaR2Light from "@/app/assets/workspace-active-cycles/cta-r-2-light.webp?url"; // components import { ProIcon } from "@/components/common/pro-icon"; // hooks @@ -93,7 +100,7 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
{
- r-1 + r-1 - r-2 + r-2
diff --git a/apps/web/ce/components/analytics/tabs.tsx b/apps/web/ce/components/analytics/tabs.tsx index eb8344c054..3cca973990 100644 --- a/apps/web/ce/components/analytics/tabs.tsx +++ b/apps/web/ce/components/analytics/tabs.tsx @@ -1,4 +1,4 @@ -import { AnalyticsTab } from "@plane/types"; +import type { AnalyticsTab } from "@plane/types"; import { Overview } from "@/components/analytics/overview"; import { WorkItems } from "@/components/analytics/work-items"; diff --git a/apps/web/ce/components/automations/list/wrapper.tsx b/apps/web/ce/components/automations/list/wrapper.tsx new file mode 100644 index 0000000000..1db9b9da6c --- /dev/null +++ b/apps/web/ce/components/automations/list/wrapper.tsx @@ -0,0 +1,7 @@ +type Props = { + projectId: string; + workspaceSlug: string; + children: React.ReactNode; +}; + +export const AutomationsListWrapper: React.FC = (props) => <>{props.children}; diff --git a/apps/web/ce/components/automations/root.tsx b/apps/web/ce/components/automations/root.tsx index 6585809116..e7f15288b0 100644 --- a/apps/web/ce/components/automations/root.tsx +++ b/apps/web/ce/components/automations/root.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; export type TCustomAutomationsRootProps = { projectId: string; diff --git a/apps/web/ce/components/breadcrumbs/common.tsx b/apps/web/ce/components/breadcrumbs/common.tsx index abcb5cb3d1..86a123915a 100644 --- a/apps/web/ce/components/breadcrumbs/common.tsx +++ b/apps/web/ce/components/breadcrumbs/common.tsx @@ -1,8 +1,8 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // plane imports -import { EProjectFeatureKey } from "@plane/constants"; +import type { EProjectFeatureKey } from "@plane/constants"; // local components import { ProjectBreadcrumb } from "./project"; import { ProjectFeatureBreadcrumb } from "./project-feature"; diff --git a/apps/web/ce/components/breadcrumbs/project-feature.tsx b/apps/web/ce/components/breadcrumbs/project-feature.tsx index ba67aa9cbc..cad4338d35 100644 --- a/apps/web/ce/components/breadcrumbs/project-feature.tsx +++ b/apps/web/ce/components/breadcrumbs/project-feature.tsx @@ -1,10 +1,10 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { EProjectFeatureKey } from "@plane/constants"; -import { ISvgIcons } from "@plane/propel/icons"; +import type { ISvgIcons } from "@plane/propel/icons"; import { BreadcrumbNavigationDropdown, Breadcrumbs } from "@plane/ui"; // components import { SwitcherLabel } from "@/components/common/switcher-label"; diff --git a/apps/web/ce/components/breadcrumbs/project.tsx b/apps/web/ce/components/breadcrumbs/project.tsx index 1bf3594e31..2f6c67bd71 100644 --- a/apps/web/ce/components/breadcrumbs/project.tsx +++ b/apps/web/ce/components/breadcrumbs/project.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { ProjectIcon } from "@plane/propel/icons"; // plane imports -import { ICustomSearchSelectOption } from "@plane/types"; +import type { ICustomSearchSelectOption } from "@plane/types"; import { BreadcrumbNavigationSearchDropdown, Breadcrumbs } from "@plane/ui"; // components import { Logo } from "@/components/common/logo"; @@ -11,7 +11,7 @@ import { SwitcherLabel } from "@/components/common/switcher-label"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useAppRouter } from "@/hooks/use-app-router"; -import { TProject } from "@/plane-web/types"; +import type { TProject } from "@/plane-web/types"; type TProjectBreadcrumbProps = { workspaceSlug: string; diff --git a/apps/web/ce/components/command-palette/helpers.tsx b/apps/web/ce/components/command-palette/helpers.tsx index 2b49a87a57..1ad7e6f498 100644 --- a/apps/web/ce/components/command-palette/helpers.tsx +++ b/apps/web/ce/components/command-palette/helpers.tsx @@ -1,17 +1,15 @@ "use client"; -// types import { LayoutGrid } from "lucide-react"; +// plane imports import { CycleIcon, ModuleIcon, PageIcon, ProjectIcon, ViewsIcon } from "@plane/propel/icons"; -import { +import type { IWorkspaceDefaultSearchResult, IWorkspaceIssueSearchResult, IWorkspacePageSearchResult, IWorkspaceProjectSearchResult, IWorkspaceSearchResult, } from "@plane/types"; -// ui -// helpers import { generateWorkItemLink } from "@plane/utils"; // plane web components import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; @@ -95,7 +93,7 @@ export const commandGroups: TCommandGroups = { if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId; return redirectProjectId ? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}` - : `/${page?.workspace__slug}/pages/${page?.id}`; + : `/${page?.workspace__slug}/wiki/${page?.id}`; }, title: "Pages", }, diff --git a/apps/web/ce/components/command-palette/index.ts b/apps/web/ce/components/command-palette/index.ts index 62404249d7..cb220b2bd9 100644 --- a/apps/web/ce/components/command-palette/index.ts +++ b/apps/web/ce/components/command-palette/index.ts @@ -1,3 +1,2 @@ export * from "./actions"; -export * from "./modals"; export * from "./helpers"; diff --git a/apps/web/ce/components/command-palette/modals/index.ts b/apps/web/ce/components/command-palette/modals/index.ts deleted file mode 100644 index a4fac4b91e..0000000000 --- a/apps/web/ce/components/command-palette/modals/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./workspace-level"; -export * from "./project-level"; -export * from "./issue-level"; diff --git a/apps/web/ce/components/command-palette/modals/issue-level.tsx b/apps/web/ce/components/command-palette/modals/work-item-level.tsx similarity index 72% rename from apps/web/ce/components/command-palette/modals/issue-level.tsx rename to apps/web/ce/components/command-palette/modals/work-item-level.tsx index b30d5ec30e..d06602d7e2 100644 --- a/apps/web/ce/components/command-palette/modals/issue-level.tsx +++ b/apps/web/ce/components/command-palette/modals/work-item-level.tsx @@ -1,8 +1,9 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import { EIssueServiceType, EIssuesStoreType, TIssue } from "@plane/types"; +import type { TIssue } from "@plane/types"; +import { EIssueServiceType, EIssuesStoreType } from "@plane/types"; // components import { BulkDeleteIssuesModal } from "@/components/core/modals/bulk-delete-issues-modal"; import { DeleteIssueModal } from "@/components/issues/delete-issue-modal"; @@ -14,21 +15,23 @@ import { useUser } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; import { useIssuesActions } from "@/hooks/use-issues-actions"; -export type TIssueLevelModalsProps = { - projectId: string | undefined; - issueId: string | undefined; +export type TWorkItemLevelModalsProps = { + workItemIdentifier: string | undefined; }; -export const IssueLevelModals: FC = observer((props) => { - const { projectId, issueId } = props; +export const WorkItemLevelModals: FC = observer((props) => { + const { workItemIdentifier } = props; // router const { workspaceSlug, cycleId, moduleId } = useParams(); const router = useAppRouter(); // store hooks const { data: currentUser } = useUser(); const { - issue: { getIssueById }, + issue: { getIssueById, getIssueIdByIdentifier }, } = useIssueDetail(); + // derived values + const workItemId = workItemIdentifier ? getIssueIdByIdentifier(workItemIdentifier) : undefined; + const workItemDetails = workItemId ? getIssueById(workItemId) : undefined; const { removeIssue: removeEpic } = useIssuesActions(EIssuesStoreType.EPIC); const { removeIssue: removeWorkItem } = useIssuesActions(EIssuesStoreType.PROJECT); @@ -43,13 +46,12 @@ export const IssueLevelModals: FC = observer((props) => createWorkItemAllowedProjectIds, } = useCommandPalette(); // derived values - const issueDetails = issueId ? getIssueById(issueId) : undefined; const { fetchSubIssues: fetchSubWorkItems } = useIssueDetail(); const { fetchSubIssues: fetchEpicSubWorkItems } = useIssueDetail(EIssueServiceType.EPICS); const handleDeleteIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { try { - const isEpic = issueDetails?.is_epic; + const isEpic = workItemDetails?.is_epic; const deleteAction = isEpic ? removeEpic : removeWorkItem; const redirectPath = `/${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}`; @@ -61,10 +63,10 @@ export const IssueLevelModals: FC = observer((props) => }; const handleCreateIssueSubmit = async (newIssue: TIssue) => { - if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== issueDetails?.id) return; + if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== workItemDetails?.id) return; - const fetchAction = issueDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems; - await fetchAction(workspaceSlug?.toString(), newIssue.project_id, issueDetails.id); + const fetchAction = workItemDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems; + await fetchAction(workspaceSlug?.toString(), newIssue.project_id, workItemDetails.id); }; const getCreateIssueModalData = () => { @@ -82,13 +84,15 @@ export const IssueLevelModals: FC = observer((props) => onSubmit={handleCreateIssueSubmit} allowedProjectIds={createWorkItemAllowedProjectIds} /> - {workspaceSlug && projectId && issueId && issueDetails && ( + {workspaceSlug && workItemId && workItemDetails && workItemDetails.project_id && ( toggleDeleteIssueModal(false)} isOpen={isDeleteIssueModalOpen} - data={issueDetails} - onSubmit={() => handleDeleteIssue(workspaceSlug.toString(), projectId?.toString(), issueId?.toString())} - isEpic={issueDetails?.is_epic} + data={workItemDetails} + onSubmit={() => + handleDeleteIssue(workspaceSlug.toString(), workItemDetails.project_id!, workItemId?.toString()) + } + isEpic={workItemDetails?.is_epic} /> )} = {}; diff --git a/apps/web/ce/components/command-palette/power-k/context-detector.ts b/apps/web/ce/components/command-palette/power-k/context-detector.ts new file mode 100644 index 0000000000..acc803bdc8 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/context-detector.ts @@ -0,0 +1,5 @@ +import type { Params } from "next/dist/shared/lib/router/utils/route-matcher"; +// local imports +import type { TPowerKContextTypeExtended } from "./types"; + +export const detectExtendedContextFromURL = (_params: Params): TPowerKContextTypeExtended | null => null; diff --git a/apps/web/ce/components/command-palette/power-k/hooks/use-extended-context-indicator.ts b/apps/web/ce/components/command-palette/power-k/hooks/use-extended-context-indicator.ts new file mode 100644 index 0000000000..ad5f43860b --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/hooks/use-extended-context-indicator.ts @@ -0,0 +1,8 @@ +// local imports +import type { TPowerKContextTypeExtended } from "../types"; + +type TArgs = { + activeContext: TPowerKContextTypeExtended | null; +}; + +export const useExtendedContextIndicator = (_args: TArgs): string | null => null; diff --git a/apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts b/apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/command-palette/power-k/pages/context-based/root.tsx b/apps/web/ce/components/command-palette/power-k/pages/context-based/root.tsx new file mode 100644 index 0000000000..2c6c0e8913 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/pages/context-based/root.tsx @@ -0,0 +1,11 @@ +// components +import type { TPowerKCommandConfig } from "@/components/power-k/core/types"; +import type { ContextBasedActionsProps, TContextEntityMap } from "@/components/power-k/ui/pages/context-based"; +// local imports +import type { TPowerKContextTypeExtended } from "../../types"; + +export const CONTEXT_ENTITY_MAP_EXTENDED: Record = {}; + +export const PowerKContextBasedActionsExtended: React.FC = () => null; + +export const usePowerKContextBasedExtendedActions = (): TPowerKCommandConfig[] => []; diff --git a/apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx b/apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx new file mode 100644 index 0000000000..5fbc91edf8 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane types +import { StateGroupIcon } from "@plane/propel/icons"; +import type { IState } from "@plane/types"; +// components +import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item"; + +export type TPowerKProjectStatesMenuItemsProps = { + handleSelect: (stateId: string) => void; + projectId: string | undefined; + selectedStateId: string | undefined; + states: IState[]; + workspaceSlug: string; +}; + +export const PowerKProjectStatesMenuItems: React.FC = observer((props) => { + const { handleSelect, selectedStateId, states } = props; + + return ( + <> + {states.map((state) => ( + } + label={state.name} + isSelected={state.id === selectedStateId} + onSelect={() => handleSelect(state.id)} + /> + ))} + + ); +}); diff --git a/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx b/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx new file mode 100644 index 0000000000..cc8ca10d51 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx @@ -0,0 +1,36 @@ +import { Command } from "cmdk"; +import { Search } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import type { TPowerKContext } from "@/components/power-k/core/types"; +// plane web imports +import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item"; + +export type TPowerKModalNoSearchResultsCommandProps = { + context: TPowerKContext; + searchTerm: string; + updateSearchTerm: (value: string) => void; +}; + +export const PowerKModalNoSearchResultsCommand: React.FC = (props) => { + const { updateSearchTerm } = props; + // translation + const { t } = useTranslation(); + + return ( + + + {t("power_k.search_menu.no_results")}{" "} + {t("power_k.search_menu.clear_search")} +

+ } + onSelect={() => updateSearchTerm("")} + /> +
+ ); +}; diff --git a/apps/web/ce/components/command-palette/power-k/search/search-results-map.tsx b/apps/web/ce/components/command-palette/power-k/search/search-results-map.tsx new file mode 100644 index 0000000000..c09dd41a10 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/search/search-results-map.tsx @@ -0,0 +1,10 @@ +"use client"; + +// components +import type { TPowerKSearchResultGroupDetails } from "@/components/power-k/ui/modal/search-results-map"; +// local imports +import type { TPowerKSearchResultsKeysExtended } from "../types"; + +type TSearchResultsGroupsMapExtended = Record; + +export const SEARCH_RESULTS_GROUPS_MAP_EXTENDED: TSearchResultsGroupsMapExtended = {}; diff --git a/apps/web/ce/components/command-palette/power-k/types.ts b/apps/web/ce/components/command-palette/power-k/types.ts new file mode 100644 index 0000000000..4e497f8b87 --- /dev/null +++ b/apps/web/ce/components/command-palette/power-k/types.ts @@ -0,0 +1,5 @@ +export type TPowerKContextTypeExtended = never; + +export type TPowerKPageTypeExtended = never; + +export type TPowerKSearchResultsKeysExtended = never; diff --git a/apps/web/ce/components/comments/comment-block.tsx b/apps/web/ce/components/comments/comment-block.tsx index 6ed7fc0946..c5c9b442a1 100644 --- a/apps/web/ce/components/comments/comment-block.tsx +++ b/apps/web/ce/components/comments/comment-block.tsx @@ -1,8 +1,10 @@ -import { FC, ReactNode, useRef } from "react"; +import type { FC, ReactNode } from "react"; +import { useRef } from "react"; import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { EIssueCommentAccessSpecifier, TIssueComment } from "@plane/types"; +import type { TIssueComment } from "@plane/types"; +import { EIssueCommentAccessSpecifier } from "@plane/types"; import { Avatar, Tooltip } from "@plane/ui"; import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils"; // hooks diff --git a/apps/web/ce/components/common/extended-app-header.tsx b/apps/web/ce/components/common/extended-app-header.tsx index 5a2df91cb1..59dbf3394d 100644 --- a/apps/web/ce/components/common/extended-app-header.tsx +++ b/apps/web/ce/components/common/extended-app-header.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { observer } from "mobx-react"; import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button"; import { useAppTheme } from "@/hooks/store/use-app-theme"; diff --git a/apps/web/ce/components/common/subscription/subscription-pill.tsx b/apps/web/ce/components/common/subscription/subscription-pill.tsx index 836ffae2bb..ba30d3ad68 100644 --- a/apps/web/ce/components/common/subscription/subscription-pill.tsx +++ b/apps/web/ce/components/common/subscription/subscription-pill.tsx @@ -1,4 +1,4 @@ -import { IWorkspace } from "@plane/types"; +import type { IWorkspace } from "@plane/types"; type TProps = { workspace?: IWorkspace; diff --git a/apps/web/ce/components/cycles/active-cycle/root.tsx b/apps/web/ce/components/cycles/active-cycle/root.tsx index 66a580cbde..b0933218e6 100644 --- a/apps/web/ce/components/cycles/active-cycle/root.tsx +++ b/apps/web/ce/components/cycles/active-cycle/root.tsx @@ -2,10 +2,14 @@ import { useMemo } from "react"; import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; import { Disclosure } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; import { Row } from "@plane/ui"; +// assets +import darkActiveCycleAsset from "@/app/assets/empty-state/cycle/active-dark.webp?url"; +import lightActiveCycleAsset from "@/app/assets/empty-state/cycle/active-light.webp?url"; // components import { ActiveCycleStats } from "@/components/cycles/active-cycle/cycle-stats"; import { ActiveCycleProductivity } from "@/components/cycles/active-cycle/productivity"; @@ -16,8 +20,7 @@ import { CyclesListItem } from "@/components/cycles/list/cycles-list-item"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; // hooks import { useCycle } from "@/hooks/store/use-cycle"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; -import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; +import type { ActiveCycleIssueDetails } from "@/store/issue/cycle"; interface IActiveCycleDetails { workspaceSlug: string; @@ -28,13 +31,15 @@ interface IActiveCycleDetails { export const ActiveCycleRoot: React.FC = observer((props) => { const { workspaceSlug, projectId, cycleId: propsCycleId, showHeader = true } = props; + // theme hook + const { resolvedTheme } = useTheme(); // plane hooks const { t } = useTranslation(); // store hooks const { currentProjectActiveCycleId } = useCycle(); // derived values const cycleId = propsCycleId ?? currentProjectActiveCycleId; - const activeCycleResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/cycle/active" }); + const activeCycleResolvedPath = resolvedTheme === "light" ? lightActiveCycleAsset : darkActiveCycleAsset; // fetch cycle details const { handleFiltersUpdate, diff --git a/apps/web/ce/components/cycles/additional-actions.tsx b/apps/web/ce/components/cycles/additional-actions.tsx index 1fcb7146fc..0fd9efb314 100644 --- a/apps/web/ce/components/cycles/additional-actions.tsx +++ b/apps/web/ce/components/cycles/additional-actions.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; type Props = { cycleId: string; diff --git a/apps/web/ce/components/cycles/analytics-sidebar/base.tsx b/apps/web/ce/components/cycles/analytics-sidebar/base.tsx index c9c56990a6..37a0707748 100644 --- a/apps/web/ce/components/cycles/analytics-sidebar/base.tsx +++ b/apps/web/ce/components/cycles/analytics-sidebar/base.tsx @@ -1,9 +1,10 @@ "use client"; -import { FC, Fragment } from "react"; +import type { FC } from "react"; +import { Fragment } from "react"; import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TCycleEstimateType } from "@plane/types"; +import type { TCycleEstimateType } from "@plane/types"; import { Loader } from "@plane/ui"; import { getDate } from "@plane/utils"; // components diff --git a/apps/web/ce/components/cycles/analytics-sidebar/root.tsx b/apps/web/ce/components/cycles/analytics-sidebar/root.tsx index d18f9168dc..6be4361efa 100644 --- a/apps/web/ce/components/cycles/analytics-sidebar/root.tsx +++ b/apps/web/ce/components/cycles/analytics-sidebar/root.tsx @@ -1,5 +1,6 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; // components import { SidebarChart } from "./base"; diff --git a/apps/web/ce/components/de-dupe/de-dupe-button.tsx b/apps/web/ce/components/de-dupe/de-dupe-button.tsx index eaa4e3b7c8..94d800ca86 100644 --- a/apps/web/ce/components/de-dupe/de-dupe-button.tsx +++ b/apps/web/ce/components/de-dupe/de-dupe-button.tsx @@ -1,5 +1,6 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; // local components type TDeDupeButtonRoot = { diff --git a/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx b/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx index 42284c6edd..55eb084fd2 100644 --- a/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx +++ b/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx @@ -1,8 +1,8 @@ "use-client"; -import { FC } from "react"; +import type { FC } from "react"; // types -import { TDeDupeIssue } from "@plane/types"; +import type { TDeDupeIssue } from "@plane/types"; type TDuplicateModalRootProps = { workspaceSlug: string; diff --git a/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx b/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx index e1b8e21688..3dd227cc80 100644 --- a/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx +++ b/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; // types -import { TDeDupeIssue } from "@plane/types"; +import type { TDeDupeIssue } from "@plane/types"; import type { TIssueOperations } from "@/components/issues/issue-detail"; type TDeDupeIssuePopoverRootProps = { diff --git a/apps/web/ce/components/de-dupe/issue-block/button-label.tsx b/apps/web/ce/components/de-dupe/issue-block/button-label.tsx index 303b0cec66..d6e363456b 100644 --- a/apps/web/ce/components/de-dupe/issue-block/button-label.tsx +++ b/apps/web/ce/components/de-dupe/issue-block/button-label.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; type TDeDupeIssueButtonLabelProps = { isOpen: boolean; diff --git a/apps/web/ce/components/epics/epic-modal/modal.tsx b/apps/web/ce/components/epics/epic-modal/modal.tsx index 9c76b7bdab..f1fec6ba84 100644 --- a/apps/web/ce/components/epics/epic-modal/modal.tsx +++ b/apps/web/ce/components/epics/epic-modal/modal.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC } from "react"; -import { TIssue } from "@plane/types"; +import type { FC } from "react"; +import React from "react"; +import type { TIssue } from "@plane/types"; export interface EpicModalProps { data?: Partial; diff --git a/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx b/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx index 7cde59b8a3..936fdc622b 100644 --- a/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx +++ b/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { Pen, Trash } from "lucide-react"; import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; diff --git a/apps/web/ce/components/estimates/helper.tsx b/apps/web/ce/components/estimates/helper.tsx index 5a1d9eaf44..71b5be8a18 100644 --- a/apps/web/ce/components/estimates/helper.tsx +++ b/apps/web/ce/components/estimates/helper.tsx @@ -1,4 +1,5 @@ -import { TEstimateSystemKeys, EEstimateSystem } from "@plane/types"; +import type { TEstimateSystemKeys } from "@plane/types"; +import { EEstimateSystem } from "@plane/types"; export const isEstimateSystemEnabled = (key: TEstimateSystemKeys) => { switch (key) { diff --git a/apps/web/ce/components/estimates/inputs/time-input.tsx b/apps/web/ce/components/estimates/inputs/time-input.tsx index 0e5156cb6a..39341ac3b9 100644 --- a/apps/web/ce/components/estimates/inputs/time-input.tsx +++ b/apps/web/ce/components/estimates/inputs/time-input.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; export type TEstimateTimeInputProps = { value?: number; diff --git a/apps/web/ce/components/estimates/points/delete.tsx b/apps/web/ce/components/estimates/points/delete.tsx index 84791392bb..522a28c961 100644 --- a/apps/web/ce/components/estimates/points/delete.tsx +++ b/apps/web/ce/components/estimates/points/delete.tsx @@ -1,8 +1,8 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; -import { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; +import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; export type TEstimatePointDelete = { workspaceSlug: string; diff --git a/apps/web/ce/components/estimates/update/modal.tsx b/apps/web/ce/components/estimates/update/modal.tsx index 12b4ea6f66..fd43171966 100644 --- a/apps/web/ce/components/estimates/update/modal.tsx +++ b/apps/web/ce/components/estimates/update/modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; type TUpdateEstimateModal = { diff --git a/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx b/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx index ec7e52fd12..ecab672dde 100644 --- a/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx +++ b/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx @@ -1,11 +1,11 @@ -import { FC } from "react"; +import type { FC } from "react"; // components import type { IBlockUpdateData, IGanttBlock } from "@plane/types"; import RenderIfVisible from "@/components/core/render-if-visible-HOC"; // hooks import { BlockRow } from "@/components/gantt-chart/blocks/block-row"; import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants"; -import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; // types export type GanttChartBlocksProps = { diff --git a/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx b/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx index 0c8f2a7c0b..593e805027 100644 --- a/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx +++ b/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; // import type { IBlockUpdateDependencyData } from "@plane/types"; import { GanttChartBlock } from "@/components/gantt-chart/blocks/block"; diff --git a/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx index cb1f33f79f..a68118b6cb 100644 --- a/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx +++ b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx @@ -1,4 +1,4 @@ -import { RefObject } from "react"; +import type { RefObject } from "react"; import type { IGanttBlock } from "@plane/types"; type LeftDependencyDraggableProps = { diff --git a/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx index 29c731c9d1..7a36ec9b33 100644 --- a/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx +++ b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx @@ -1,4 +1,4 @@ -import { RefObject } from "react"; +import type { RefObject } from "react"; import type { IGanttBlock } from "@plane/types"; type RightDependencyDraggableProps = { diff --git a/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx b/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx index 6feb208a8b..e52805e178 100644 --- a/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx +++ b/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; type Props = { isEpic?: boolean; diff --git a/apps/web/ce/components/inbox/source-pill.tsx b/apps/web/ce/components/inbox/source-pill.tsx index 07e7217807..77d3038cbc 100644 --- a/apps/web/ce/components/inbox/source-pill.tsx +++ b/apps/web/ce/components/inbox/source-pill.tsx @@ -1,4 +1,4 @@ -import { EInboxIssueSource } from "@plane/types"; +import type { EInboxIssueSource } from "@plane/types"; export type TInboxSourcePill = { source: EInboxIssueSource; diff --git a/apps/web/ce/components/issues/bulk-operations/root.tsx b/apps/web/ce/components/issues/bulk-operations/root.tsx index dbd1455068..fe7fcfe1be 100644 --- a/apps/web/ce/components/issues/bulk-operations/root.tsx +++ b/apps/web/ce/components/issues/bulk-operations/root.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { BulkOperationsUpgradeBanner } from "@/components/issues/bulk-operations/upgrade-banner"; // hooks import { useMultipleSelectStore } from "@/hooks/store/use-multiple-select-store"; -import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; type Props = { className?: string; diff --git a/apps/web/ce/components/issues/filters/issue-types.tsx b/apps/web/ce/components/issues/filters/issue-types.tsx index bc364c8f80..4d983bb7d4 100644 --- a/apps/web/ce/components/issues/filters/issue-types.tsx +++ b/apps/web/ce/components/issues/filters/issue-types.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import type React from "react"; import { observer } from "mobx-react"; type Props = { diff --git a/apps/web/ce/components/issues/filters/team-project.tsx b/apps/web/ce/components/issues/filters/team-project.tsx index 4f4787fef8..c8975deb47 100644 --- a/apps/web/ce/components/issues/filters/team-project.tsx +++ b/apps/web/ce/components/issues/filters/team-project.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import type React from "react"; import { observer } from "mobx-react"; type Props = { diff --git a/apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx b/apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx index 1312c08395..b0f33932f4 100644 --- a/apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx +++ b/apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx @@ -1,6 +1,6 @@ -import { FC } from "react"; +import type { FC } from "react"; // plane types -import { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; +import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; export type TWorkItemAdditionalWidgetActionButtonsProps = { disabled: boolean; diff --git a/apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx b/apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx index a9a6a1b295..2632987f84 100644 --- a/apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx +++ b/apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx @@ -1,6 +1,6 @@ -import { FC } from "react"; +import type { FC } from "react"; // plane types -import { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; +import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; export type TWorkItemAdditionalWidgetCollapsiblesProps = { disabled: boolean; diff --git a/apps/web/ce/components/issues/issue-detail-widgets/modals.tsx b/apps/web/ce/components/issues/issue-detail-widgets/modals.tsx index 2e9dfe40d5..b478cf8983 100644 --- a/apps/web/ce/components/issues/issue-detail-widgets/modals.tsx +++ b/apps/web/ce/components/issues/issue-detail-widgets/modals.tsx @@ -1,6 +1,6 @@ -import { FC } from "react"; +import type { FC } from "react"; // plane types -import { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; +import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; export type TWorkItemAdditionalWidgetModalsProps = { hideWidgets: TWorkItemWidgets[]; diff --git a/apps/web/ce/components/issues/issue-details/additional-activity-root.tsx b/apps/web/ce/components/issues/issue-details/additional-activity-root.tsx index cd0383e6e2..448deabc8e 100644 --- a/apps/web/ce/components/issues/issue-details/additional-activity-root.tsx +++ b/apps/web/ce/components/issues/issue-details/additional-activity-root.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; export type TAdditionalActivityRoot = { diff --git a/apps/web/ce/components/issues/issue-details/additional-properties.tsx b/apps/web/ce/components/issues/issue-details/additional-properties.tsx index 64b8caa97d..2e0a14707b 100644 --- a/apps/web/ce/components/issues/issue-details/additional-properties.tsx +++ b/apps/web/ce/components/issues/issue-details/additional-properties.tsx @@ -1,4 +1,5 @@ -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; // plane imports export type TWorkItemAdditionalSidebarProperties = { diff --git a/apps/web/ce/components/issues/issue-details/issue-creator.tsx b/apps/web/ce/components/issues/issue-details/issue-creator.tsx index c1e65ff5f3..f3435e2888 100644 --- a/apps/web/ce/components/issues/issue-details/issue-creator.tsx +++ b/apps/web/ce/components/issues/issue-details/issue-creator.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import Link from "next/link"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/ce/components/issues/issue-details/issue-identifier.tsx b/apps/web/ce/components/issues/issue-details/issue-identifier.tsx index b85e16536d..c81b0075b5 100644 --- a/apps/web/ce/components/issues/issue-details/issue-identifier.tsx +++ b/apps/web/ce/components/issues/issue-details/issue-identifier.tsx @@ -1,9 +1,9 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // types import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import { IIssueDisplayProperties } from "@plane/types"; +import type { IIssueDisplayProperties } from "@plane/types"; // ui // helpers import { cn } from "@plane/utils"; diff --git a/apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx b/apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx index cac7556764..6aeb6eda01 100644 --- a/apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx +++ b/apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; type TIssueAdditionalPropertiesActivity = { activityId: string; diff --git a/apps/web/ce/components/issues/issue-details/issue-type-activity.tsx b/apps/web/ce/components/issues/issue-details/issue-type-activity.tsx index 8def50f48c..5796555ce5 100644 --- a/apps/web/ce/components/issues/issue-details/issue-type-activity.tsx +++ b/apps/web/ce/components/issues/issue-details/issue-type-activity.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; export type TIssueTypeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; diff --git a/apps/web/ce/components/issues/issue-details/sidebar.tsx/date-alert.tsx b/apps/web/ce/components/issues/issue-details/sidebar.tsx/date-alert.tsx new file mode 100644 index 0000000000..930d459496 --- /dev/null +++ b/apps/web/ce/components/issues/issue-details/sidebar.tsx/date-alert.tsx @@ -0,0 +1,9 @@ +import type { TIssue } from "@plane/types"; + +export type TDateAlertProps = { + date: string; + workItem: TIssue; + projectId: string; +}; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const DateAlert = (props: TDateAlertProps) => <>; diff --git a/apps/web/ce/components/issues/issue-layouts/additional-properties.tsx b/apps/web/ce/components/issues/issue-layouts/additional-properties.tsx index 8f8b8ac04c..1c397c5721 100644 --- a/apps/web/ce/components/issues/issue-layouts/additional-properties.tsx +++ b/apps/web/ce/components/issues/issue-layouts/additional-properties.tsx @@ -1,5 +1,6 @@ -import React, { FC } from "react"; -import { IIssueDisplayProperties, TIssue } from "@plane/types"; +import type { FC } from "react"; +import React from "react"; +import type { IIssueDisplayProperties, TIssue } from "@plane/types"; export type TWorkItemLayoutAdditionalProperties = { displayProperties: IIssueDisplayProperties; diff --git a/apps/web/ce/components/issues/issue-layouts/issue-stats.tsx b/apps/web/ce/components/issues/issue-layouts/issue-stats.tsx index 13542280db..b6e80910c5 100644 --- a/apps/web/ce/components/issues/issue-layouts/issue-stats.tsx +++ b/apps/web/ce/components/issues/issue-layouts/issue-stats.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; type Props = { issueId: string; diff --git a/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx index 28ac44dc9c..7f4bb031b9 100644 --- a/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx +++ b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx @@ -1,5 +1,5 @@ -import { Copy } from "lucide-react"; -import { TContextMenuItem } from "@plane/ui"; +import type { Copy } from "lucide-react"; +import type { TContextMenuItem } from "@plane/ui"; export interface CopyMenuHelperProps { baseItem: { diff --git a/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx index 1ea30e26e6..761317a013 100644 --- a/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx +++ b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; type TDuplicateWorkItemModalProps = { workItemId: string; diff --git a/apps/web/ce/components/issues/issue-layouts/utils.tsx b/apps/web/ce/components/issues/issue-layouts/utils.tsx index ac4139de47..82a26a00c6 100644 --- a/apps/web/ce/components/issues/issue-layouts/utils.tsx +++ b/apps/web/ce/components/issues/issue-layouts/utils.tsx @@ -1,19 +1,26 @@ -import { FC } from "react"; -import { - CalendarCheck2, - CalendarClock, - CalendarDays, - LayersIcon, - Link2, - Paperclip, - Signal, - Tag, - Triangle, - Users, -} from "lucide-react"; +import type { FC } from "react"; +import { CalendarDays, LayersIcon, Link2, Paperclip } from "lucide-react"; // types -import { CycleIcon, DoubleCircleIcon, ISvgIcons, ModuleIcon } from "@plane/propel/icons"; -import { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types"; +import { ISSUE_GROUP_BY_OPTIONS } from "@plane/constants"; +import type { ISvgIcons } from "@plane/propel/icons"; +import { + CycleIcon, + StatePropertyIcon, + ModuleIcon, + MembersPropertyIcon, + DueDatePropertyIcon, + EstimatePropertyIcon, + LabelPropertyIcon, + PriorityPropertyIcon, + StartDatePropertyIcon, +} from "@plane/propel/icons"; +import type { + IGroupByColumn, + IIssueDisplayProperties, + TGetColumns, + TIssueGroupByOptions, + TSpreadsheetColumn, +} from "@plane/types"; // components import { SpreadsheetAssigneeColumn, @@ -65,16 +72,16 @@ export const getScopeMemberIds = ({ isWorkspaceLevel, projectId }: TGetColumns): export const getTeamProjectColumns = (): IGroupByColumn[] | undefined => undefined; export const SpreadSheetPropertyIconMap: Record> = { - Users: Users, + MembersPropertyIcon: MembersPropertyIcon, CalenderDays: CalendarDays, - CalenderCheck2: CalendarCheck2, - Triangle: Triangle, - Tag: Tag, + DueDatePropertyIcon: DueDatePropertyIcon, + EstimatePropertyIcon: EstimatePropertyIcon, + LabelPropertyIcon: LabelPropertyIcon, ModuleIcon: ModuleIcon, ContrastIcon: CycleIcon, - Signal: Signal, - CalendarClock: CalendarClock, - DoubleCircleIcon: DoubleCircleIcon, + PriorityPropertyIcon: PriorityPropertyIcon, + StartDatePropertyIcon: StartDatePropertyIcon, + StatePropertyIcon: StatePropertyIcon, Link2: Link2, Paperclip: Paperclip, LayersIcon: LayersIcon, @@ -96,3 +103,13 @@ export const SPREADSHEET_COLUMNS: { [key in keyof IIssueDisplayProperties]: TSpr updated_on: SpreadsheetUpdatedOnColumn, attachment_count: SpreadsheetAttachmentColumn, }; + +export const useGroupByOptions = ( + options: TIssueGroupByOptions[] +): { + key: TIssueGroupByOptions; + titleTranslationKey: string; +}[] => { + const groupByOptions = ISSUE_GROUP_BY_OPTIONS.filter((option) => options.includes(option.key)); + return groupByOptions; +}; diff --git a/apps/web/ce/components/issues/issue-modal/issue-type-select.tsx b/apps/web/ce/components/issues/issue-modal/issue-type-select.tsx index 00a192be12..ab73750ed6 100644 --- a/apps/web/ce/components/issues/issue-modal/issue-type-select.tsx +++ b/apps/web/ce/components/issues/issue-modal/issue-type-select.tsx @@ -1,8 +1,8 @@ -import { Control } from "react-hook-form"; +import type { Control } from "react-hook-form"; // plane imports import type { EditorRefApi } from "@plane/editor"; // types -import { TBulkIssueProperties, TIssue } from "@plane/types"; +import type { TBulkIssueProperties, TIssue } from "@plane/types"; export type TIssueFields = TIssue & TBulkIssueProperties; diff --git a/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx b/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx index ad35b48922..091d0c7ae2 100644 --- a/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx +++ b/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import type React from "react"; export type TWorkItemModalAdditionalPropertiesProps = { isDraft?: boolean; diff --git a/apps/web/ce/components/issues/issue-modal/provider.tsx b/apps/web/ce/components/issues/issue-modal/provider.tsx index 55b0d4bb79..bd623cdda4 100644 --- a/apps/web/ce/components/issues/issue-modal/provider.tsx +++ b/apps/web/ce/components/issues/issue-modal/provider.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; // plane imports -import { ISearchIssueResponse, TIssue } from "@plane/types"; +import type { ISearchIssueResponse, TIssue } from "@plane/types"; // components import { IssueModalContext } from "@/components/issues/issue-modal/context"; // hooks diff --git a/apps/web/ce/components/issues/quick-add/root.tsx b/apps/web/ce/components/issues/quick-add/root.tsx index d74c3bf755..e01d4bd219 100644 --- a/apps/web/ce/components/issues/quick-add/root.tsx +++ b/apps/web/ce/components/issues/quick-add/root.tsx @@ -1,19 +1,21 @@ -import { FC, useEffect, useRef } from "react"; +import type { FC } from "react"; +import { useEffect, useRef } from "react"; import { observer } from "mobx-react"; -import { UseFormRegister, UseFormSetFocus } from "react-hook-form"; +import type { UseFormRegister, UseFormSetFocus } from "react-hook-form"; // plane constants // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; // types -import { TIssue, EIssueLayoutTypes } from "@plane/types"; +import type { TIssue } from "@plane/types"; +import { EIssueLayoutTypes } from "@plane/types"; // components +import type { TQuickAddIssueForm } from "@/components/issues/issue-layouts/quick-add"; import { CalendarQuickAddIssueForm, GanttQuickAddIssueForm, KanbanQuickAddIssueForm, ListQuickAddIssueForm, SpreadsheetQuickAddIssueForm, - TQuickAddIssueForm, } from "@/components/issues/issue-layouts/quick-add"; // hooks import { useProject } from "@/hooks/store/use-project"; diff --git a/apps/web/ce/components/issues/worklog/activity/filter-root.tsx b/apps/web/ce/components/issues/worklog/activity/filter-root.tsx index a2fe9910f3..cbc4607fb2 100644 --- a/apps/web/ce/components/issues/worklog/activity/filter-root.tsx +++ b/apps/web/ce/components/issues/worklog/activity/filter-root.tsx @@ -1,8 +1,9 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // plane imports -import { TActivityFilters, ACTIVITY_FILTER_TYPE_OPTIONS, TActivityFilterOption } from "@plane/constants"; +import type { TActivityFilters, TActivityFilterOption } from "@plane/constants"; +import { ACTIVITY_FILTER_TYPE_OPTIONS } from "@plane/constants"; // components import { ActivityFilter } from "@/components/issues/issue-detail/issue-activity"; diff --git a/apps/web/ce/components/issues/worklog/activity/root.tsx b/apps/web/ce/components/issues/worklog/activity/root.tsx index 0342999d3d..42a0a28eb6 100644 --- a/apps/web/ce/components/issues/worklog/activity/root.tsx +++ b/apps/web/ce/components/issues/worklog/activity/root.tsx @@ -1,7 +1,7 @@ "use client"; -import { FC } from "react"; -import { TIssueActivityComment } from "@plane/types"; +import type { FC } from "react"; +import type { TIssueActivityComment } from "@plane/types"; type TIssueActivityWorklog = { workspaceSlug: string; diff --git a/apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx b/apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx index 3a57b53df4..17acfb2f47 100644 --- a/apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx +++ b/apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; type TIssueActivityWorklogCreateButton = { workspaceSlug: string; diff --git a/apps/web/ce/components/issues/worklog/property/root.tsx b/apps/web/ce/components/issues/worklog/property/root.tsx index 5ccc9ebaad..a0a4d28999 100644 --- a/apps/web/ce/components/issues/worklog/property/root.tsx +++ b/apps/web/ce/components/issues/worklog/property/root.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; type TIssueWorklogProperty = { workspaceSlug: string; diff --git a/apps/web/ce/components/license/modal/upgrade-modal.tsx b/apps/web/ce/components/license/modal/upgrade-modal.tsx index 531925c040..5917fc7645 100644 --- a/apps/web/ce/components/license/modal/upgrade-modal.tsx +++ b/apps/web/ce/components/license/modal/upgrade-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { @@ -17,7 +17,7 @@ import { EModalWidth, ModalCore } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { FreePlanCard, PlanUpgradeCard } from "@/components/license"; -import { TCheckoutParams } from "@/components/license/modal/card/checkout-button"; +import type { TCheckoutParams } from "@/components/license/modal/card/checkout-button"; // Constants const COMMON_CARD_CLASSNAME = "flex flex-col w-full h-full justify-end col-span-12 sm:col-span-6 xl:col-span-3"; diff --git a/apps/web/ce/components/pages/editor/ai/menu.tsx b/apps/web/ce/components/pages/editor/ai/menu.tsx index 6d79584abb..033c8ff88e 100644 --- a/apps/web/ce/components/pages/editor/ai/menu.tsx +++ b/apps/web/ce/components/pages/editor/ai/menu.tsx @@ -1,9 +1,11 @@ "use client"; import React, { useEffect, useRef, useState } from "react"; -import { ChevronRight, CornerDownRight, LucideIcon, RefreshCcw, Sparkles, TriangleAlert } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { CornerDownRight, RefreshCcw, Sparkles, TriangleAlert } from "lucide-react"; // plane editor import type { EditorRefApi } from "@plane/editor"; +import { ChevronRightIcon } from "@plane/propel/icons"; // plane ui import { Tooltip } from "@plane/propel/tooltip"; // components @@ -12,7 +14,8 @@ import { RichTextEditor } from "@/components/editor/rich-text"; // plane web constants import { AI_EDITOR_TASKS, LOADING_TEXTS } from "@/plane-web/constants/ai"; // plane web services -import { AIService, TTaskPayload } from "@/services/ai.service"; +import type { TTaskPayload } from "@/services/ai.service"; +import { AIService } from "@/services/ai.service"; import { AskPiMenu } from "./ask-pi-menu"; const aiService = new AIService(); @@ -172,7 +175,7 @@ export const EditorAIMenu: React.FC = (props) => { {item.label} - { + // theme hook + const { resolvedTheme } = useTheme(); // asset resolved path - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/wiki/navigation-pane/assets" }); + const resolvedPath = resolvedTheme === "light" ? lightAssetsAsset : darkAssetsAsset; // translation const { t } = useTranslation(); diff --git a/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx b/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx index 1e692ea3b3..9b6744cc9c 100644 --- a/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx +++ b/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx @@ -1,12 +1,16 @@ import Image from "next/image"; +import { useTheme } from "next-themes"; // plane imports import { useTranslation } from "@plane/i18n"; -// hooks -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +// assets +import darkOutlineAsset from "@/app/assets/empty-state/wiki/navigation-pane/outline-dark.webp?url"; +import lightOutlineAsset from "@/app/assets/empty-state/wiki/navigation-pane/outline-light.webp?url"; export const PageNavigationPaneOutlineTabEmptyState = () => { + // theme hook + const { resolvedTheme } = useTheme(); // asset resolved path - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/wiki/navigation-pane/outline" }); + const resolvedPath = resolvedTheme === "light" ? lightOutlineAsset : darkOutlineAsset; // translation const { t } = useTranslation(); diff --git a/apps/web/ce/components/pages/navigation-pane/tab-panels/root.tsx b/apps/web/ce/components/pages/navigation-pane/tab-panels/root.tsx index 93419437a9..1581b4948f 100644 --- a/apps/web/ce/components/pages/navigation-pane/tab-panels/root.tsx +++ b/apps/web/ce/components/pages/navigation-pane/tab-panels/root.tsx @@ -1,7 +1,7 @@ // store import type { TPageInstance } from "@/store/pages/base-page"; // local imports -import { TPageNavigationPaneTab } from ".."; +import type { TPageNavigationPaneTab } from ".."; export type TPageNavigationPaneAdditionalTabPanelsRootProps = { activeTab: TPageNavigationPaneTab; diff --git a/apps/web/ce/components/preferences/theme-switcher.tsx b/apps/web/ce/components/preferences/theme-switcher.tsx index 773c589930..0460fbcaf7 100644 --- a/apps/web/ce/components/preferences/theme-switcher.tsx +++ b/apps/web/ce/components/preferences/theme-switcher.tsx @@ -4,10 +4,11 @@ import { useEffect, useState, useCallback } from "react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; // plane imports -import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants"; +import type { I_THEME_OPTION } from "@plane/constants"; +import { THEME_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { setPromiseToast } from "@plane/propel/toast"; -import { IUserTheme } from "@plane/types"; +import type { IUserTheme } from "@plane/types"; import { applyTheme, unsetCustomCssVariables } from "@plane/utils"; // components import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; diff --git a/apps/web/ce/components/projects/create/attributes.tsx b/apps/web/ce/components/projects/create/attributes.tsx index e445510338..e1119f0522 100644 --- a/apps/web/ce/components/projects/create/attributes.tsx +++ b/apps/web/ce/components/projects/create/attributes.tsx @@ -1,10 +1,10 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { Controller, useFormContext } from "react-hook-form"; // plane imports import { NETWORK_CHOICES, ETabIndices } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IProject } from "@plane/types"; +import type { IProject } from "@plane/types"; import { CustomSelect } from "@plane/ui"; import { getTabIndex } from "@plane/utils"; // components diff --git a/apps/web/ce/components/projects/create/root.tsx b/apps/web/ce/components/projects/create/root.tsx index 06105458ae..27abe7aad4 100644 --- a/apps/web/ce/components/projects/create/root.tsx +++ b/apps/web/ce/components/projects/create/root.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, FC } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import { FormProvider, useForm } from "react-hook-form"; import { DEFAULT_PROJECT_FORM_VALUES, PROJECT_TRACKER_EVENTS } from "@plane/constants"; @@ -16,7 +17,7 @@ import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; import { useProject } from "@/hooks/store/use-project"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web types -import { TProject } from "@/plane-web/types/projects"; +import type { TProject } from "@/plane-web/types/projects"; import ProjectAttributes from "./attributes"; export type TCreateProjectFormProps = { diff --git a/apps/web/ce/components/projects/mobile-header.tsx b/apps/web/ce/components/projects/mobile-header.tsx index d9b84f3b52..855ea2d8f6 100644 --- a/apps/web/ce/components/projects/mobile-header.tsx +++ b/apps/web/ce/components/projects/mobile-header.tsx @@ -2,10 +2,11 @@ import { useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { ChevronDown, ListFilter } from "lucide-react"; +import { ListFilter } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TProjectFilters } from "@plane/types"; +import { ChevronDownIcon } from "@plane/propel/icons"; +import type { TProjectFilters } from "@plane/types"; import { calculateTotalFilters } from "@plane/utils"; // components import { FiltersDropdown } from "@/components/issues/issue-layouts/filters"; @@ -72,7 +73,7 @@ export const ProjectsListMobileHeader = observer(() => {
{t("common.filters")} - +
} isFiltersApplied={isFiltersApplied} diff --git a/apps/web/ce/components/projects/settings/features-list.tsx b/apps/web/ce/components/projects/settings/features-list.tsx new file mode 100644 index 0000000000..26fc591fdb --- /dev/null +++ b/apps/web/ce/components/projects/settings/features-list.tsx @@ -0,0 +1 @@ +export { ProjectFeaturesList } from "@/components/project/settings/features-list"; diff --git a/apps/web/ce/components/projects/settings/intake/header.tsx b/apps/web/ce/components/projects/settings/intake/header.tsx index 0c8f61c968..692eecd141 100644 --- a/apps/web/ce/components/projects/settings/intake/header.tsx +++ b/apps/web/ce/components/projects/settings/intake/header.tsx @@ -1,6 +1,7 @@ "use client"; -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { RefreshCcw } from "lucide-react"; diff --git a/apps/web/ce/components/projects/settings/useProjectColumns.tsx b/apps/web/ce/components/projects/settings/useProjectColumns.tsx index 881851427d..43f8983c6e 100644 --- a/apps/web/ce/components/projects/settings/useProjectColumns.tsx +++ b/apps/web/ce/components/projects/settings/useProjectColumns.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { IWorkspaceMember, TProjectMembership } from "@plane/types"; +import type { IWorkspaceMember, TProjectMembership } from "@plane/types"; import { renderFormattedDate } from "@plane/utils"; // components import { MemberHeaderColumn } from "@/components/project/member-header-column"; @@ -9,7 +9,7 @@ import { AccountTypeColumn, NameColumn } from "@/components/project/settings/mem // hooks import { useMember } from "@/hooks/store/use-member"; import { useUser, useUserPermissions } from "@/hooks/store/user"; -import { IMemberFilters } from "@/store/member/utils"; +import type { IMemberFilters } from "@/store/member/utils"; export interface RowData extends Pick { member: IWorkspaceMember; diff --git a/apps/web/ce/components/relations/activity.ts b/apps/web/ce/components/relations/activity.ts index 3b39ae5fe7..820966f4d3 100644 --- a/apps/web/ce/components/relations/activity.ts +++ b/apps/web/ce/components/relations/activity.ts @@ -1,4 +1,4 @@ -import { TIssueActivity } from "@plane/types"; +import type { TIssueActivity } from "@plane/types"; export const getRelationActivityContent = (activity: TIssueActivity | undefined): string | undefined => { if (!activity) return; diff --git a/apps/web/ce/components/relations/index.tsx b/apps/web/ce/components/relations/index.tsx index 2a7ebf0ebc..f9d72944c2 100644 --- a/apps/web/ce/components/relations/index.tsx +++ b/apps/web/ce/components/relations/index.tsx @@ -1,5 +1,5 @@ -import { CircleDot, CopyPlus, XCircle } from "lucide-react"; -import { RelatedIcon } from "@plane/propel/icons"; +import { CircleDot, XCircle } from "lucide-react"; +import { RelatedIcon, DuplicatePropertyIcon } from "@plane/propel/icons"; import type { TRelationObject } from "@/components/issues/issue-detail-widgets/relations"; import type { TIssueRelationTypes } from "../../types"; @@ -17,7 +17,7 @@ export const ISSUE_RELATION_OPTIONS: Record , + icon: (size) => , placeholder: "None", }, blocked_by: { diff --git a/apps/web/ce/components/rich-filters/filter-value-input/root.tsx b/apps/web/ce/components/rich-filters/filter-value-input/root.tsx index 5a3842164c..f2ef9aba42 100644 --- a/apps/web/ce/components/rich-filters/filter-value-input/root.tsx +++ b/apps/web/ce/components/rich-filters/filter-value-input/root.tsx @@ -1,9 +1,9 @@ import React from "react"; import { observer } from "mobx-react"; // plane imports -import { TFilterValue, TFilterProperty } from "@plane/types"; +import type { TFilterValue, TFilterProperty } from "@plane/types"; // local imports -import { TFilterValueInputProps } from "@/components/rich-filters/shared"; +import type { TFilterValueInputProps } from "@/components/rich-filters/shared"; export const AdditionalFilterValueInput = observer(

(_props: TFilterValueInputProps) => ( diff --git a/apps/web/ce/components/sidebar/project-navigation-root.tsx b/apps/web/ce/components/sidebar/project-navigation-root.tsx index 89972c23dd..d4ca7bc325 100644 --- a/apps/web/ce/components/sidebar/project-navigation-root.tsx +++ b/apps/web/ce/components/sidebar/project-navigation-root.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // components import { ProjectNavigation } from "@/components/workspace/sidebar/project-navigation"; diff --git a/apps/web/ce/components/views/helper.tsx b/apps/web/ce/components/views/helper.tsx index 6e8b2381c2..d2932ddac6 100644 --- a/apps/web/ce/components/views/helper.tsx +++ b/apps/web/ce/components/views/helper.tsx @@ -1,8 +1,8 @@ import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { EIssueLayoutTypes, IProjectView } from "@plane/types"; -import { TContextMenuItem } from "@plane/ui"; -import { TWorkspaceLayoutProps } from "@/components/views/helper"; +import type { EIssueLayoutTypes, IProjectView } from "@plane/types"; +import type { TContextMenuItem } from "@plane/ui"; +import type { TWorkspaceLayoutProps } from "@/components/views/helper"; export type TLayoutSelectionProps = { onChange: (layout: EIssueLayoutTypes) => void; diff --git a/apps/web/ce/components/views/publish/modal.tsx b/apps/web/ce/components/views/publish/modal.tsx index 0951de0930..f92b3138d4 100644 --- a/apps/web/ce/components/views/publish/modal.tsx +++ b/apps/web/ce/components/views/publish/modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { IProjectView } from "@plane/types"; +import type { IProjectView } from "@plane/types"; type Props = { isOpen: boolean; diff --git a/apps/web/ce/components/workflow/use-workflow-drag-n-drop.ts b/apps/web/ce/components/workflow/use-workflow-drag-n-drop.ts index f6117a0d80..20c97eb4fd 100644 --- a/apps/web/ce/components/workflow/use-workflow-drag-n-drop.ts +++ b/apps/web/ce/components/workflow/use-workflow-drag-n-drop.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { TIssueGroupByOptions } from "@plane/types"; +import type { TIssueGroupByOptions } from "@plane/types"; export const useWorkFlowFDragNDrop = ( groupBy: TIssueGroupByOptions | undefined, diff --git a/apps/web/ce/components/workflow/workflow-group-tree.tsx b/apps/web/ce/components/workflow/workflow-group-tree.tsx index 5caed41705..bc4cf9b1ca 100644 --- a/apps/web/ce/components/workflow/workflow-group-tree.tsx +++ b/apps/web/ce/components/workflow/workflow-group-tree.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { TIssueGroupByOptions } from "@plane/types"; +import type { TIssueGroupByOptions } from "@plane/types"; type Props = { groupBy?: TIssueGroupByOptions; diff --git a/apps/web/ce/components/workspace-notifications/notification-card/root.tsx b/apps/web/ce/components/workspace-notifications/notification-card/root.tsx index ef2eb11de4..214b0fb9c4 100644 --- a/apps/web/ce/components/workspace-notifications/notification-card/root.tsx +++ b/apps/web/ce/components/workspace-notifications/notification-card/root.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants"; diff --git a/apps/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx b/apps/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx index a4fec060f9..2993f32926 100644 --- a/apps/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx +++ b/apps/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx @@ -1,7 +1,7 @@ -import { FC } from "react"; +import type { FC } from "react"; // plane imports import { observer } from "mobx-react"; -import { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; +import type { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; import { getSubscriptionBackgroundColor, getDiscountPillStyle } from "@plane/ui"; import { calculateYearlyDiscount, cn } from "@plane/utils"; diff --git a/apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx b/apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx index f7612e911e..03af2fa323 100644 --- a/apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx +++ b/apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { @@ -10,12 +10,13 @@ import { } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { getButtonStyling } from "@plane/propel/button"; -import { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; +import type { TBillingFrequency } from "@plane/types"; +import { EProductSubscriptionEnum } from "@plane/types"; import { getUpgradeButtonStyle } from "@plane/ui"; import { cn, getSubscriptionName } from "@plane/utils"; // components import { DiscountInfo } from "@/components/license/modal/card/discount-info"; -import { TPlanDetail } from "@/constants/plans"; +import type { TPlanDetail } from "@/constants/plans"; // local imports import { captureSuccess } from "@/helpers/event-tracker.helper"; import { PlanFrequencyToggle } from "./frequency-toggle"; diff --git a/apps/web/ce/components/workspace/billing/comparison/root.tsx b/apps/web/ce/components/workspace/billing/comparison/root.tsx index cf8f3dec52..3c09582755 100644 --- a/apps/web/ce/components/workspace/billing/comparison/root.tsx +++ b/apps/web/ce/components/workspace/billing/comparison/root.tsx @@ -1,9 +1,10 @@ import { observer } from "mobx-react"; // plane imports -import { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; +import type { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; // components import { PlansComparisonBase, shouldRenderPlanDetail } from "@/components/workspace/billing/comparison/base"; -import { PLANE_PLANS, TPlanePlans } from "@/constants/plans"; +import type { TPlanePlans } from "@/constants/plans"; +import { PLANE_PLANS } from "@/constants/plans"; // plane web imports import { PlanDetail } from "./plan-detail"; diff --git a/apps/web/ce/components/workspace/billing/root.tsx b/apps/web/ce/components/workspace/billing/root.tsx index d5bc225d4e..e9ac6e1892 100644 --- a/apps/web/ce/components/workspace/billing/root.tsx +++ b/apps/web/ce/components/workspace/billing/root.tsx @@ -3,7 +3,8 @@ import { observer } from "mobx-react"; // plane imports import { DEFAULT_PRODUCT_BILLING_FREQUENCY, SUBSCRIPTION_WITH_BILLING_FREQUENCY } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EProductSubscriptionEnum, TBillingFrequency, TProductBillingFrequency } from "@plane/types"; +import type { TBillingFrequency, TProductBillingFrequency } from "@plane/types"; +import { EProductSubscriptionEnum } from "@plane/types"; import { getSubscriptionTextColor } from "@plane/ui"; import { cn } from "@plane/utils"; // components diff --git a/apps/web/ce/components/workspace/delete-workspace-section.tsx b/apps/web/ce/components/workspace/delete-workspace-section.tsx index 17b4632cbd..a9975b8f59 100644 --- a/apps/web/ce/components/workspace/delete-workspace-section.tsx +++ b/apps/web/ce/components/workspace/delete-workspace-section.tsx @@ -1,11 +1,12 @@ -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; -import { ChevronDown, ChevronUp } from "lucide-react"; // types import { WORKSPACE_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; -import { IWorkspace } from "@plane/types"; +import { ChevronDownIcon, ChevronUpIcon } from "@plane/propel/icons"; +import type { IWorkspace } from "@plane/types"; // ui import { Collapsible } from "@plane/ui"; import { DeleteWorkspaceModal } from "./delete-workspace-modal"; @@ -41,7 +42,7 @@ export const DeleteWorkspaceSection: FC = observer((props) => {t("workspace_settings.settings.general.delete_workspace")} - {isOpen ? : } + {isOpen ? : } } > diff --git a/apps/web/ce/components/workspace/members/invite-modal.tsx b/apps/web/ce/components/workspace/members/invite-modal.tsx index 8641847bd7..f83234e217 100644 --- a/apps/web/ce/components/workspace/members/invite-modal.tsx +++ b/apps/web/ce/components/workspace/members/invite-modal.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports import { useTranslation } from "@plane/i18n"; -import { IWorkspaceBulkInviteFormData } from "@plane/types"; +import type { IWorkspaceBulkInviteFormData } from "@plane/types"; import { EModalWidth, EModalPosition, ModalCore } from "@plane/ui"; // components import { InvitationModalActions } from "@/components/workspace/invite-modal/actions"; diff --git a/apps/web/ce/components/workspace/settings/useMemberColumns.tsx b/apps/web/ce/components/workspace/settings/useMemberColumns.tsx index 7142bf720e..8f2286a6f5 100644 --- a/apps/web/ce/components/workspace/settings/useMemberColumns.tsx +++ b/apps/web/ce/components/workspace/settings/useMemberColumns.tsx @@ -4,10 +4,11 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { renderFormattedDate } from "@plane/utils"; import { MemberHeaderColumn } from "@/components/project/member-header-column"; -import { AccountTypeColumn, NameColumn, RowData } from "@/components/workspace/settings/member-columns"; +import type { RowData } from "@/components/workspace/settings/member-columns"; +import { AccountTypeColumn, NameColumn } from "@/components/workspace/settings/member-columns"; import { useMember } from "@/hooks/store/use-member"; import { useUser, useUserPermissions } from "@/hooks/store/user"; -import { IMemberFilters } from "@/store/member/utils"; +import type { IMemberFilters } from "@/store/member/utils"; export const useMemberColumns = () => { // states diff --git a/apps/web/ce/components/workspace/sidebar/app-search.tsx b/apps/web/ce/components/workspace/sidebar/app-search.tsx index 77a3593733..89d2607cf8 100644 --- a/apps/web/ce/components/workspace/sidebar/app-search.tsx +++ b/apps/web/ce/components/workspace/sidebar/app-search.tsx @@ -1,24 +1,24 @@ import { observer } from "mobx-react"; -import { Search } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; +// components +import { SidebarSearchButton } from "@/components/sidebar/search-button"; // hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { usePowerK } from "@/hooks/store/use-power-k"; export const AppSearch = observer(() => { // store hooks - const { toggleCommandPaletteModal } = useCommandPalette(); + const { togglePowerKModal } = usePowerK(); // translation const { t } = useTranslation(); return ( ); }); diff --git a/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx b/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx index ea16bc6b3f..44b47c706b 100644 --- a/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx +++ b/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx @@ -1,4 +1,5 @@ -import { FC, useEffect, useRef, useState } from "react"; +import type { FC } from "react"; +import { useEffect, useRef, useState } from "react"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; @@ -7,7 +8,8 @@ import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { Pin, PinOff } from "lucide-react"; // plane imports -import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants"; +import type { IWorkspaceSidebarNavigationItem } from "@plane/constants"; +import { EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Tooltip } from "@plane/propel/tooltip"; import { DragHandle, DropIndicator } from "@plane/ui"; diff --git a/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx b/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx index 349042806c..3fbc8d0a24 100644 --- a/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx +++ b/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx @@ -1,5 +1,5 @@ -import { FC } from "react"; -import { IWorkspaceSidebarNavigationItem } from "@plane/constants"; +import type { FC } from "react"; +import type { IWorkspaceSidebarNavigationItem } from "@plane/constants"; import { SidebarItemBase } from "@/components/workspace/sidebar/sidebar-item"; type Props = { diff --git a/apps/web/ce/components/workspace/upgrade-badge.tsx b/apps/web/ce/components/workspace/upgrade-badge.tsx index 8c198dd2e6..17efeb0157 100644 --- a/apps/web/ce/components/workspace/upgrade-badge.tsx +++ b/apps/web/ce/components/workspace/upgrade-badge.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; // helpers import { useTranslation } from "@plane/i18n"; import { cn } from "@plane/utils"; diff --git a/apps/web/ce/constants/editor.ts b/apps/web/ce/constants/editor.ts index b9a6d5d383..f018796026 100644 --- a/apps/web/ce/constants/editor.ts +++ b/apps/web/ce/constants/editor.ts @@ -1,4 +1,4 @@ // plane types -import { TSearchEntities } from "@plane/types"; +import type { TSearchEntities } from "@plane/types"; export const EDITOR_MENTION_TYPES: TSearchEntities[] = ["user_mention"]; diff --git a/apps/web/ce/constants/gantt-chart.ts b/apps/web/ce/constants/gantt-chart.ts index 228b94b5bf..95e39bcc8a 100644 --- a/apps/web/ce/constants/gantt-chart.ts +++ b/apps/web/ce/constants/gantt-chart.ts @@ -1,4 +1,4 @@ -import { TIssueRelationTypes } from "../types"; +import type { TIssueRelationTypes } from "../types"; export const REVERSE_RELATIONS: { [key in TIssueRelationTypes]: TIssueRelationTypes } = { blocked_by: "blocking", diff --git a/apps/web/ce/constants/project/settings/features.tsx b/apps/web/ce/constants/project/settings/features.tsx index 923d2db840..380272ea4a 100644 --- a/apps/web/ce/constants/project/settings/features.tsx +++ b/apps/web/ce/constants/project/settings/features.tsx @@ -1,8 +1,8 @@ -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { Timer } from "lucide-react"; // plane imports import { CycleIcon, IntakeIcon, ModuleIcon, PageIcon, ViewsIcon } from "@plane/propel/icons"; -import { IProject } from "@plane/types"; +import type { IProject } from "@plane/types"; export type TProperties = { key: string; @@ -13,6 +13,7 @@ export type TProperties = { isPro: boolean; isEnabled: boolean; renderChildren?: (currentProjectDetails: IProject, workspaceSlug: string) => ReactNode; + href?: string; }; type TProjectBaseFeatureKeys = "cycles" | "modules" | "views" | "pages" | "inbox"; diff --git a/apps/web/ce/constants/project/settings/tabs.ts b/apps/web/ce/constants/project/settings/tabs.ts index 5443c64245..f78b51a74f 100644 --- a/apps/web/ce/constants/project/settings/tabs.ts +++ b/apps/web/ce/constants/project/settings/tabs.ts @@ -2,7 +2,7 @@ import { EUserPermissions } from "@plane/constants"; import { SettingIcon } from "@/components/icons/attachment"; // types -import { Props } from "@/components/icons/types"; +import type { Props } from "@/components/icons/types"; // constants export const PROJECT_SETTINGS = { diff --git a/apps/web/ce/constants/sidebar-favorites.ts b/apps/web/ce/constants/sidebar-favorites.ts index b93c9cf67f..aaa615e8ae 100644 --- a/apps/web/ce/constants/sidebar-favorites.ts +++ b/apps/web/ce/constants/sidebar-favorites.ts @@ -1,15 +1,8 @@ -import { LucideIcon } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; // plane imports -import { - CycleIcon, - FavoriteFolderIcon, - ISvgIcons, - ModuleIcon, - PageIcon, - ProjectIcon, - ViewsIcon, -} from "@plane/propel/icons"; -import { IFavorite } from "@plane/types"; +import type { ISvgIcons } from "@plane/propel/icons"; +import { CycleIcon, FavoriteFolderIcon, ModuleIcon, PageIcon, ProjectIcon, ViewsIcon } from "@plane/propel/icons"; +import type { IFavorite } from "@plane/types"; export const FAVORITE_ITEM_ICONS: Record | LucideIcon> = { page: PageIcon, diff --git a/apps/web/ce/helpers/command-palette.ts b/apps/web/ce/helpers/command-palette.ts index 875f85b258..d29660a168 100644 --- a/apps/web/ce/helpers/command-palette.ts +++ b/apps/web/ce/helpers/command-palette.ts @@ -7,7 +7,7 @@ import { PROJECT_VIEW_TRACKER_ELEMENTS, WORK_ITEM_TRACKER_ELEMENTS, } from "@plane/constants"; -import { TCommandPaletteActionList, TCommandPaletteShortcut, TCommandPaletteShortcutList } from "@plane/types"; +import type { TCommandPaletteActionList, TCommandPaletteShortcut, TCommandPaletteShortcutList } from "@plane/types"; // store import { captureClick } from "@/helpers/event-tracker.helper"; import { store } from "@/lib/store-context"; diff --git a/apps/web/ce/helpers/epic-analytics.ts b/apps/web/ce/helpers/epic-analytics.ts index 43e6ffef05..1a7a9df411 100644 --- a/apps/web/ce/helpers/epic-analytics.ts +++ b/apps/web/ce/helpers/epic-analytics.ts @@ -1,4 +1,4 @@ -import { TEpicAnalyticsGroup } from "@plane/types"; +import type { TEpicAnalyticsGroup } from "@plane/types"; export const updateEpicAnalytics = () => { const updateAnalytics = ( diff --git a/apps/web/ce/helpers/issue-action-helper.ts b/apps/web/ce/helpers/issue-action-helper.ts index fdee6bcc98..a3c66e2731 100644 --- a/apps/web/ce/helpers/issue-action-helper.ts +++ b/apps/web/ce/helpers/issue-action-helper.ts @@ -1,4 +1,4 @@ -import { IssueActions } from "@/hooks/use-issues-actions"; +import type { IssueActions } from "@/hooks/use-issues-actions"; export const useTeamIssueActions: () => IssueActions = () => ({ fetchIssues: () => Promise.resolve(undefined), diff --git a/apps/web/ce/helpers/issue-filter.helper.ts b/apps/web/ce/helpers/issue-filter.helper.ts index 925c4a63ca..48a893d3c2 100644 --- a/apps/web/ce/helpers/issue-filter.helper.ts +++ b/apps/web/ce/helpers/issue-filter.helper.ts @@ -1,5 +1,5 @@ // types -import { IIssueDisplayProperties } from "@plane/types"; +import type { IIssueDisplayProperties } from "@plane/types"; // lib import { store } from "@/lib/store-context"; diff --git a/apps/web/ce/helpers/work-item-filters/project-level.ts b/apps/web/ce/helpers/work-item-filters/project-level.ts index 97724db449..be0bc64ec9 100644 --- a/apps/web/ce/helpers/work-item-filters/project-level.ts +++ b/apps/web/ce/helpers/work-item-filters/project-level.ts @@ -1,7 +1,7 @@ // plane imports -import { EIssuesStoreType } from "@plane/types"; +import type { EIssuesStoreType } from "@plane/types"; // plane web imports -import { TWorkItemFiltersEntityProps } from "@/plane-web/hooks/work-item-filters/use-work-item-filters-config"; +import type { TWorkItemFiltersEntityProps } from "@/plane-web/hooks/work-item-filters/use-work-item-filters-config"; export type TGetAdditionalPropsForProjectLevelFiltersHOCParams = { entityType: EIssuesStoreType; diff --git a/apps/web/ce/hooks/pages/use-extended-editor-extensions.ts b/apps/web/ce/hooks/pages/use-extended-editor-extensions.ts index 028e16c889..737578481b 100644 --- a/apps/web/ce/hooks/pages/use-extended-editor-extensions.ts +++ b/apps/web/ce/hooks/pages/use-extended-editor-extensions.ts @@ -1,7 +1,7 @@ import type { IEditorPropsExtended } from "@plane/editor"; import type { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; import type { TPageInstance } from "@/store/pages/base-page"; -import { EPageStoreType } from "../store"; +import type { EPageStoreType } from "../store"; export type TExtendedEditorExtensionsHookParams = { workspaceSlug: string; diff --git a/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts b/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts index b73ffc58b0..5aef069cf6 100644 --- a/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts +++ b/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts @@ -1,4 +1,5 @@ -import { useCallback, useMemo, type RefObject } from "react"; +import { useCallback, useMemo } from "react"; +import type { RefObject } from "react"; import { useSearchParams } from "next/navigation"; import type { EditorRefApi } from "@plane/editor"; import { @@ -9,7 +10,7 @@ import { import { useAppRouter } from "@/hooks/use-app-router"; import { useQueryParams } from "@/hooks/use-query-params"; import type { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane"; -import { INavigationPaneExtension } from "@/plane-web/types/pages/pane-extensions"; +import type { INavigationPaneExtension } from "@/plane-web/types/pages/pane-extensions"; import type { TPageInstance } from "@/store/pages/base-page"; export type TPageExtensionHookParams = { diff --git a/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts b/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts index 636abce76b..0c65a4de82 100644 --- a/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts +++ b/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts @@ -1,4 +1,5 @@ -import { CORE_OPERATORS, TSupportedOperators } from "@plane/types"; +import type { TSupportedOperators } from "@plane/types"; +import { CORE_OPERATORS } from "@plane/types"; export type TFiltersOperatorConfigs = { allowedOperators: Set; diff --git a/apps/web/ce/hooks/store/use-page-store.ts b/apps/web/ce/hooks/store/use-page-store.ts index 91bf9306be..025e03836c 100644 --- a/apps/web/ce/hooks/store/use-page-store.ts +++ b/apps/web/ce/hooks/store/use-page-store.ts @@ -2,7 +2,7 @@ import { useContext } from "react"; // context import { StoreContext } from "@/lib/store-context"; // mobx store -import { IProjectPageStore } from "@/store/pages/project-page.store"; +import type { IProjectPageStore } from "@/store/pages/project-page.store"; export enum EPageStoreType { PROJECT = "PROJECT_PAGE", diff --git a/apps/web/ce/hooks/store/use-page.ts b/apps/web/ce/hooks/store/use-page.ts index c7bd7ceede..d4c531fe47 100644 --- a/apps/web/ce/hooks/store/use-page.ts +++ b/apps/web/ce/hooks/store/use-page.ts @@ -2,7 +2,8 @@ import { useContext } from "react"; // mobx store import { StoreContext } from "@/lib/store-context"; // plane web hooks -import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; +import type { EPageStoreType } from "@/plane-web/hooks/store"; +import { usePageStore } from "@/plane-web/hooks/store"; export type TArgs = { pageId: string; diff --git a/apps/web/ce/hooks/use-additional-favorite-item-details.ts b/apps/web/ce/hooks/use-additional-favorite-item-details.ts index 412f4a39a2..7d1a6d36fa 100644 --- a/apps/web/ce/hooks/use-additional-favorite-item-details.ts +++ b/apps/web/ce/hooks/use-additional-favorite-item-details.ts @@ -1,5 +1,5 @@ // plane imports -import { IFavorite } from "@plane/types"; +import type { IFavorite } from "@plane/types"; // components import { getFavoriteItemIcon } from "@/components/workspace/sidebar/favorites/favorite-items/common"; diff --git a/apps/web/ce/hooks/use-debounced-duplicate-issues.tsx b/apps/web/ce/hooks/use-debounced-duplicate-issues.tsx index 8028a61910..b8c32d1bda 100644 --- a/apps/web/ce/hooks/use-debounced-duplicate-issues.tsx +++ b/apps/web/ce/hooks/use-debounced-duplicate-issues.tsx @@ -1,4 +1,4 @@ -import { TDeDupeIssue } from "@plane/types"; +import type { TDeDupeIssue } from "@plane/types"; export const useDebouncedDuplicateIssues = ( workspaceSlug: string | undefined, diff --git a/apps/web/ce/hooks/use-editor-flagging.ts b/apps/web/ce/hooks/use-editor-flagging.ts index f8ced83630..731c6715e7 100644 --- a/apps/web/ce/hooks/use-editor-flagging.ts +++ b/apps/web/ce/hooks/use-editor-flagging.ts @@ -1,6 +1,6 @@ // editor import type { TExtensions } from "@plane/editor"; -import { EPageStoreType } from "@/plane-web/hooks/store"; +import type { EPageStoreType } from "@/plane-web/hooks/store"; export type TEditorFlaggingHookReturnType = { document: { diff --git a/apps/web/ce/hooks/use-issue-properties.tsx b/apps/web/ce/hooks/use-issue-properties.tsx index c4d35d6add..12a020c8ce 100644 --- a/apps/web/ce/hooks/use-issue-properties.tsx +++ b/apps/web/ce/hooks/use-issue-properties.tsx @@ -1,4 +1,4 @@ -import { TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; export const useWorkItemProperties = ( projectId: string | null | undefined, diff --git a/apps/web/ce/hooks/use-notification-preview.tsx b/apps/web/ce/hooks/use-notification-preview.tsx index 7492ea105e..6e21868a5f 100644 --- a/apps/web/ce/hooks/use-notification-preview.tsx +++ b/apps/web/ce/hooks/use-notification-preview.tsx @@ -1,7 +1,8 @@ -import { EIssueServiceType, IWorkItemPeekOverview } from "@plane/types"; +import type { IWorkItemPeekOverview } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; import { IssuePeekOverview } from "@/components/issues/peek-overview"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { TPeekIssue } from "@/store/issue/issue-details/root.store"; +import type { TPeekIssue } from "@/store/issue/issue-details/root.store"; export type TNotificationPreview = { isWorkItem: boolean; diff --git a/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx b/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx index 8a194defba..90ad9394db 100644 --- a/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx +++ b/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx @@ -1,25 +1,21 @@ import { useCallback, useMemo } from "react"; -import { - AtSign, - Briefcase, - Calendar, - CalendarCheck2, - CalendarClock, - CircleUserRound, - SignalHigh, - Tag, - Users, -} from "lucide-react"; +import { AtSign, Briefcase, Calendar } from "lucide-react"; // plane imports import { CycleGroupIcon, CycleIcon, ModuleIcon, - DoubleCircleIcon, + StatePropertyIcon, PriorityIcon, StateGroupIcon, + MembersPropertyIcon, + LabelPropertyIcon, + StartDatePropertyIcon, + DueDatePropertyIcon, + UserCirclePropertyIcon, + PriorityPropertyIcon, } from "@plane/propel/icons"; -import { +import type { ICycle, IState, IUserLite, @@ -149,7 +145,7 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): () => getStateGroupFilterConfig("state_group")({ isEnabled: isFilterEnabled("state_group"), - filterIcon: DoubleCircleIcon, + filterIcon: StatePropertyIcon, getOptionIcon: (stateGroupKey) => , ...operatorConfigs, }), @@ -161,7 +157,7 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): () => getStateFilterConfig("state_id")({ isEnabled: isFilterEnabled("state_id") && workItemStates !== undefined, - filterIcon: DoubleCircleIcon, + filterIcon: StatePropertyIcon, getOptionIcon: (state) => , states: workItemStates ?? [], ...operatorConfigs, @@ -174,7 +170,7 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): () => getLabelFilterConfig("label_id")({ isEnabled: isFilterEnabled("label_id") && workItemLabels !== undefined, - filterIcon: Tag, + filterIcon: LabelPropertyIcon, labels: workItemLabels ?? [], getOptionIcon: (color) => ( @@ -215,7 +211,7 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): () => getAssigneeFilterConfig("assignee_id")({ isEnabled: isFilterEnabled("assignee_id") && members !== undefined, - filterIcon: Users, + filterIcon: MembersPropertyIcon, members: members ?? [], getOptionIcon: (memberDetails) => ( getCreatedByFilterConfig("created_by_id")({ isEnabled: isFilterEnabled("created_by_id") && members !== undefined, - filterIcon: CircleUserRound, + filterIcon: UserCirclePropertyIcon, members: members ?? [], getOptionIcon: (memberDetails) => ( getSubscriberFilterConfig("subscriber_id")({ isEnabled: isFilterEnabled("subscriber_id") && members !== undefined, - filterIcon: Users, + filterIcon: MembersPropertyIcon, members: members ?? [], getOptionIcon: (memberDetails) => ( getPriorityFilterConfig("priority")({ isEnabled: isFilterEnabled("priority"), - filterIcon: SignalHigh, + filterIcon: PriorityPropertyIcon, getOptionIcon: (priority) => , ...operatorConfigs, }), @@ -307,7 +303,7 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): () => getStartDateFilterConfig("start_date")({ isEnabled: true, - filterIcon: CalendarClock, + filterIcon: StartDatePropertyIcon, ...operatorConfigs, }), [operatorConfigs] @@ -318,7 +314,7 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): () => getTargetDateFilterConfig("target_date")({ isEnabled: true, - filterIcon: CalendarCheck2, + filterIcon: DueDatePropertyIcon, ...operatorConfigs, }), [operatorConfigs] diff --git a/apps/web/ce/layouts/project-wrapper.tsx b/apps/web/ce/layouts/project-wrapper.tsx index 6c566b0a33..d2e2b1a995 100644 --- a/apps/web/ce/layouts/project-wrapper.tsx +++ b/apps/web/ce/layouts/project-wrapper.tsx @@ -1,11 +1,11 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // layouts import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper"; export type IProjectAuthWrapper = { workspaceSlug: string; - projectId: string; + projectId?: string; children: React.ReactNode; }; diff --git a/apps/web/ce/layouts/workspace-wrapper.tsx b/apps/web/ce/layouts/workspace-wrapper.tsx index 3fa52a5d1c..47a272d63c 100644 --- a/apps/web/ce/layouts/workspace-wrapper.tsx +++ b/apps/web/ce/layouts/workspace-wrapper.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // layouts import { WorkspaceAuthWrapper as CoreWorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper"; diff --git a/apps/web/ce/services/project/estimate.service.ts b/apps/web/ce/services/project/estimate.service.ts index f33c15efb2..95b9a39a45 100644 --- a/apps/web/ce/services/project/estimate.service.ts +++ b/apps/web/ce/services/project/estimate.service.ts @@ -2,7 +2,7 @@ // types import { API_BASE_URL } from "@plane/constants"; -import { IEstimate, IEstimateFormData, IEstimatePoint } from "@plane/types"; +import type { IEstimate, IEstimateFormData, IEstimatePoint } from "@plane/types"; // helpers // services import { APIService } from "@/services/api.service"; diff --git a/apps/web/ce/store/analytics.store.ts b/apps/web/ce/store/analytics.store.ts index ef866f65a7..9556dcf3aa 100644 --- a/apps/web/ce/store/analytics.store.ts +++ b/apps/web/ce/store/analytics.store.ts @@ -1,4 +1,5 @@ -import { BaseAnalyticsStore, IBaseAnalyticsStore } from "@/store/analytics.store"; +import type { IBaseAnalyticsStore } from "@/store/analytics.store"; +import { BaseAnalyticsStore } from "@/store/analytics.store"; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface IAnalyticsStore extends IBaseAnalyticsStore { diff --git a/apps/web/ce/store/command-palette.store.ts b/apps/web/ce/store/command-palette.store.ts index 1b6fabf187..6a3f8abae1 100644 --- a/apps/web/ce/store/command-palette.store.ts +++ b/apps/web/ce/store/command-palette.store.ts @@ -1,6 +1,7 @@ import { computed, makeObservable } from "mobx"; // types / constants -import { BaseCommandPaletteStore, IBaseCommandPaletteStore } from "@/store/base-command-palette.store"; +import type { IBaseCommandPaletteStore } from "@/store/base-command-palette.store"; +import { BaseCommandPaletteStore } from "@/store/base-command-palette.store"; export interface ICommandPaletteStore extends IBaseCommandPaletteStore { // computed diff --git a/apps/web/ce/store/estimates/estimate.ts b/apps/web/ce/store/estimates/estimate.ts index 456a7f1550..8a32799bc7 100644 --- a/apps/web/ce/store/estimates/estimate.ts +++ b/apps/web/ce/store/estimates/estimate.ts @@ -2,11 +2,16 @@ import { orderBy, set } from "lodash-es"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // types -import { IEstimate as IEstimateType, IEstimatePoint as IEstimatePointType, TEstimateSystemKeys } from "@plane/types"; +import type { + IEstimate as IEstimateType, + IEstimatePoint as IEstimatePointType, + TEstimateSystemKeys, +} from "@plane/types"; // plane web services import estimateService from "@/plane-web/services/project/estimate.service"; // store -import { IEstimatePoint, EstimatePoint } from "@/store/estimates/estimate-point"; +import type { IEstimatePoint } from "@/store/estimates/estimate-point"; +import { EstimatePoint } from "@/store/estimates/estimate-point"; import type { CoreRootStore } from "@/store/root.store"; type TErrorCodes = { diff --git a/apps/web/ce/store/issue/epic/filter.store.ts b/apps/web/ce/store/issue/epic/filter.store.ts index a4733c60a5..999e1515d3 100644 --- a/apps/web/ce/store/issue/epic/filter.store.ts +++ b/apps/web/ce/store/issue/epic/filter.store.ts @@ -1,5 +1,6 @@ -import { IProjectIssuesFilter, ProjectIssuesFilter } from "@/store/issue/project"; -import { IIssueRootStore } from "@/store/issue/root.store"; +import type { IProjectIssuesFilter } from "@/store/issue/project"; +import { ProjectIssuesFilter } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors export type IProjectEpicsFilter = IProjectIssuesFilter; diff --git a/apps/web/ce/store/issue/epic/issue.store.ts b/apps/web/ce/store/issue/epic/issue.store.ts index 90ccee84da..702a4c05ce 100644 --- a/apps/web/ce/store/issue/epic/issue.store.ts +++ b/apps/web/ce/store/issue/epic/issue.store.ts @@ -1,6 +1,7 @@ -import { IProjectIssues, ProjectIssues } from "@/store/issue/project"; -import { IIssueRootStore } from "@/store/issue/root.store"; -import { IProjectEpicsFilter } from "./filter.store"; +import type { IProjectIssues } from "@/store/issue/project"; +import { ProjectIssues } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; +import type { IProjectEpicsFilter } from "./filter.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors diff --git a/apps/web/ce/store/issue/helpers/base-issue-store.ts b/apps/web/ce/store/issue/helpers/base-issue-store.ts index b75a4916a1..eac5cec17c 100644 --- a/apps/web/ce/store/issue/helpers/base-issue-store.ts +++ b/apps/web/ce/store/issue/helpers/base-issue-store.ts @@ -1,4 +1,4 @@ -import { TIssue } from "@plane/types"; +import type { TIssue } from "@plane/types"; import { getIssueIds } from "@/store/issue/helpers/base-issues-utils"; export const workItemSortWithOrderByExtended = (array: TIssue[], key?: string) => getIssueIds(array); diff --git a/apps/web/ce/store/issue/helpers/base-issue.store.ts b/apps/web/ce/store/issue/helpers/base-issue.store.ts index b75a4916a1..eac5cec17c 100644 --- a/apps/web/ce/store/issue/helpers/base-issue.store.ts +++ b/apps/web/ce/store/issue/helpers/base-issue.store.ts @@ -1,4 +1,4 @@ -import { TIssue } from "@plane/types"; +import type { TIssue } from "@plane/types"; import { getIssueIds } from "@/store/issue/helpers/base-issues-utils"; export const workItemSortWithOrderByExtended = (array: TIssue[], key?: string) => getIssueIds(array); diff --git a/apps/web/ce/store/issue/helpers/filter-utils.ts b/apps/web/ce/store/issue/helpers/filter-utils.ts new file mode 100644 index 0000000000..eedd126240 --- /dev/null +++ b/apps/web/ce/store/issue/helpers/filter-utils.ts @@ -0,0 +1,3 @@ +import type { IIssueDisplayFilterOptions } from "@plane/types"; + +export const getEnabledDisplayFilters = (displayFilters: IIssueDisplayFilterOptions) => displayFilters; diff --git a/apps/web/ce/store/issue/issue-details/activity.store.ts b/apps/web/ce/store/issue/issue-details/activity.store.ts index 95a9ed792f..b9a70b7724 100644 --- a/apps/web/ce/store/issue/issue-details/activity.store.ts +++ b/apps/web/ce/store/issue/issue-details/activity.store.ts @@ -2,15 +2,16 @@ import { concat, orderBy, set, uniq, update } from "lodash-es"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // plane package imports -import { E_SORT_ORDER, EActivityFilterType } from "@plane/constants"; -import { - EIssueServiceType, +import type { E_SORT_ORDER } from "@plane/constants"; +import { EActivityFilterType } from "@plane/constants"; +import type { TIssueActivityComment, TIssueActivity, TIssueActivityMap, TIssueActivityIdMap, TIssueServiceType, } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // plane web constants // services import { IssueActivityService } from "@/services/issue"; @@ -45,7 +46,6 @@ export class IssueActivityStore implements IIssueActivityStore { loader: TActivityLoader = "fetch"; activities: TIssueActivityIdMap = {}; activityMap: TIssueActivityMap = {}; - // services serviceType; issueActivityService; @@ -78,10 +78,10 @@ export class IssueActivityStore implements IIssueActivityStore { return this.activityMap[activityId] ?? undefined; }; - getActivityAndCommentsByIssueId = computedFn((issueId: string, sortOrder: E_SORT_ORDER) => { + protected buildActivityAndCommentItems(issueId: string): TIssueActivityComment[] | undefined { if (!issueId) return undefined; - let activityComments: TIssueActivityComment[] = []; + const activityComments: TIssueActivityComment[] = []; const currentStore = this.serviceType === EIssueServiceType.EPICS ? this.store.issue.epicDetail : this.store.issue.issueDetail; @@ -91,29 +91,46 @@ export class IssueActivityStore implements IIssueActivityStore { if (!activities || !comments) return undefined; - activities?.forEach((activityId) => { + activities.forEach((activityId) => { const activity = this.getActivityById(activityId); if (!activity) return; + const type = + activity.field === "state" + ? EActivityFilterType.STATE + : activity.field === "assignees" + ? EActivityFilterType.ASSIGNEE + : activity.field === null + ? EActivityFilterType.DEFAULT + : EActivityFilterType.ACTIVITY; activityComments.push({ id: activity.id, - activity_type: EActivityFilterType.ACTIVITY, + activity_type: type, created_at: activity.created_at, }); }); - comments?.forEach((commentId) => { + comments.forEach((commentId) => { const comment = currentStore.comment.getCommentById(commentId); if (!comment) return; + const commentTimestamp = comment.edited_at ?? comment.updated_at ?? comment.created_at; activityComments.push({ id: comment.id, activity_type: EActivityFilterType.COMMENT, - created_at: comment.created_at, + created_at: commentTimestamp, }); }); - activityComments = orderBy(activityComments, (e) => new Date(e.created_at || 0), sortOrder); - return activityComments; + } + + protected sortActivityComments(items: TIssueActivityComment[], sortOrder: E_SORT_ORDER): TIssueActivityComment[] { + return orderBy(items, (e) => new Date(e.created_at || 0), sortOrder); + } + + getActivityAndCommentsByIssueId = computedFn((issueId: string, sortOrder: E_SORT_ORDER) => { + const baseItems = this.buildActivityAndCommentItems(issueId); + if (!baseItems) return undefined; + return this.sortActivityComments(baseItems, sortOrder); }); // actions diff --git a/apps/web/ce/store/issue/issue-details/root.store.ts b/apps/web/ce/store/issue/issue-details/root.store.ts index bbea3f46bf..2bc4f03ed3 100644 --- a/apps/web/ce/store/issue/issue-details/root.store.ts +++ b/apps/web/ce/store/issue/issue-details/root.store.ts @@ -1,10 +1,8 @@ import { makeObservable } from "mobx"; -import { TIssueServiceType } from "@plane/types"; -import { - IssueDetail as IssueDetailCore, - IIssueDetail as IIssueDetailCore, -} from "@/store/issue/issue-details/root.store"; -import { IIssueRootStore } from "@/store/issue/root.store"; +import type { TIssueServiceType } from "@plane/types"; +import type { IIssueDetail as IIssueDetailCore } from "@/store/issue/issue-details/root.store"; +import { IssueDetail as IssueDetailCore } from "@/store/issue/issue-details/root.store"; +import type { IIssueRootStore } from "@/store/issue/root.store"; export type IIssueDetail = IIssueDetailCore; diff --git a/apps/web/ce/store/issue/team-project/filter.store.ts b/apps/web/ce/store/issue/team-project/filter.store.ts index 7905325bcb..8cdb7787da 100644 --- a/apps/web/ce/store/issue/team-project/filter.store.ts +++ b/apps/web/ce/store/issue/team-project/filter.store.ts @@ -1,5 +1,6 @@ -import { IProjectIssuesFilter, ProjectIssuesFilter } from "@/store/issue/project"; -import { IIssueRootStore } from "@/store/issue/root.store"; +import type { IProjectIssuesFilter } from "@/store/issue/project"; +import { ProjectIssuesFilter } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors export type ITeamProjectWorkItemsFilter = IProjectIssuesFilter; diff --git a/apps/web/ce/store/issue/team-project/issue.store.ts b/apps/web/ce/store/issue/team-project/issue.store.ts index 2b2d408b68..496d5fda63 100644 --- a/apps/web/ce/store/issue/team-project/issue.store.ts +++ b/apps/web/ce/store/issue/team-project/issue.store.ts @@ -1,6 +1,7 @@ -import { IProjectIssues, ProjectIssues } from "@/store/issue/project"; -import { IIssueRootStore } from "@/store/issue/root.store"; -import { ITeamProjectWorkItemsFilter } from "./filter.store"; +import type { IProjectIssues } from "@/store/issue/project"; +import { ProjectIssues } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; +import type { ITeamProjectWorkItemsFilter } from "./filter.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors export type ITeamProjectWorkItems = IProjectIssues; diff --git a/apps/web/ce/store/issue/team-views/filter.store.ts b/apps/web/ce/store/issue/team-views/filter.store.ts index 9c33f94051..a40a3eaa2a 100644 --- a/apps/web/ce/store/issue/team-views/filter.store.ts +++ b/apps/web/ce/store/issue/team-views/filter.store.ts @@ -1,5 +1,6 @@ -import { IProjectViewIssuesFilter, ProjectViewIssuesFilter } from "@/store/issue/project-views"; -import { IIssueRootStore } from "@/store/issue/root.store"; +import type { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import { ProjectViewIssuesFilter } from "@/store/issue/project-views"; +import type { IIssueRootStore } from "@/store/issue/root.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors export type ITeamViewIssuesFilter = IProjectViewIssuesFilter; diff --git a/apps/web/ce/store/issue/team-views/issue.store.ts b/apps/web/ce/store/issue/team-views/issue.store.ts index 328370f853..8bcfbc67ea 100644 --- a/apps/web/ce/store/issue/team-views/issue.store.ts +++ b/apps/web/ce/store/issue/team-views/issue.store.ts @@ -1,6 +1,7 @@ -import { IProjectViewIssues, ProjectViewIssues } from "@/store/issue/project-views"; -import { IIssueRootStore } from "@/store/issue/root.store"; -import { ITeamViewIssuesFilter } from "./filter.store"; +import type { IProjectViewIssues } from "@/store/issue/project-views"; +import { ProjectViewIssues } from "@/store/issue/project-views"; +import type { IIssueRootStore } from "@/store/issue/root.store"; +import type { ITeamViewIssuesFilter } from "./filter.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors export type ITeamViewIssues = IProjectViewIssues; diff --git a/apps/web/ce/store/issue/team/filter.store.ts b/apps/web/ce/store/issue/team/filter.store.ts index 42b2d5dd24..62e1f2eb6d 100644 --- a/apps/web/ce/store/issue/team/filter.store.ts +++ b/apps/web/ce/store/issue/team/filter.store.ts @@ -1,5 +1,6 @@ -import { IProjectIssuesFilter, ProjectIssuesFilter } from "@/store/issue/project"; -import { IIssueRootStore } from "@/store/issue/root.store"; +import type { IProjectIssuesFilter } from "@/store/issue/project"; +import { ProjectIssuesFilter } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors export type ITeamIssuesFilter = IProjectIssuesFilter; diff --git a/apps/web/ce/store/issue/team/issue.store.ts b/apps/web/ce/store/issue/team/issue.store.ts index 2e39794364..446332c585 100644 --- a/apps/web/ce/store/issue/team/issue.store.ts +++ b/apps/web/ce/store/issue/team/issue.store.ts @@ -1,6 +1,7 @@ -import { IProjectIssues, ProjectIssues } from "@/store/issue/project"; -import { IIssueRootStore } from "@/store/issue/root.store"; -import { ITeamIssuesFilter } from "./filter.store"; +import type { IProjectIssues } from "@/store/issue/project"; +import { ProjectIssues } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; +import type { ITeamIssuesFilter } from "./filter.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors export type ITeamIssues = IProjectIssues; diff --git a/apps/web/ce/store/member/project-member.store.ts b/apps/web/ce/store/member/project-member.store.ts index 356ed33326..f0e5b3069d 100644 --- a/apps/web/ce/store/member/project-member.store.ts +++ b/apps/web/ce/store/member/project-member.store.ts @@ -1,11 +1,12 @@ import { computedFn } from "mobx-utils"; -import { EUserProjectRoles } from "@plane/types"; +import type { EUserProjectRoles } from "@plane/types"; // plane imports // plane web imports import type { RootStore } from "@/plane-web/store/root.store"; // store import type { IMemberRootStore } from "@/store/member"; -import { BaseProjectMemberStore, IBaseProjectMemberStore } from "@/store/member/project/base-project-member.store"; +import type { IBaseProjectMemberStore } from "@/store/member/project/base-project-member.store"; +import { BaseProjectMemberStore } from "@/store/member/project/base-project-member.store"; export type IProjectMemberStore = IBaseProjectMemberStore; diff --git a/apps/web/ce/store/power-k.store.ts b/apps/web/ce/store/power-k.store.ts new file mode 100644 index 0000000000..753d329407 --- /dev/null +++ b/apps/web/ce/store/power-k.store.ts @@ -0,0 +1,13 @@ +import { makeObservable } from "mobx"; +// types +import type { IBasePowerKStore } from "@/store/base-power-k.store"; +import { BasePowerKStore } from "@/store/base-power-k.store"; + +export type IPowerKStore = IBasePowerKStore; + +export class PowerKStore extends BasePowerKStore implements IPowerKStore { + constructor() { + super(); + makeObservable(this, {}); + } +} diff --git a/apps/web/ce/store/root.store.ts b/apps/web/ce/store/root.store.ts index 01e6513dc0..ca6caff8ca 100644 --- a/apps/web/ce/store/root.store.ts +++ b/apps/web/ce/store/root.store.ts @@ -1,6 +1,7 @@ // store import { CoreRootStore } from "@/store/root.store"; -import { ITimelineStore, TimeLineStore } from "./timeline"; +import type { ITimelineStore } from "./timeline"; +import { TimeLineStore } from "./timeline"; export class RootStore extends CoreRootStore { timelineStore: ITimelineStore; diff --git a/apps/web/ce/store/timeline/index.ts b/apps/web/ce/store/timeline/index.ts index 49a3c120b5..a6afa124c6 100644 --- a/apps/web/ce/store/timeline/index.ts +++ b/apps/web/ce/store/timeline/index.ts @@ -1,7 +1,10 @@ import type { RootStore } from "@/plane-web/store/root.store"; -import { type IIssuesTimeLineStore, IssuesTimeLineStore } from "@/store/timeline/issues-timeline.store"; -import { type IModulesTimeLineStore, ModulesTimeLineStore } from "@/store/timeline/modules-timeline.store"; -import { BaseTimeLineStore, type IBaseTimelineStore } from "./base-timeline.store"; +import { IssuesTimeLineStore } from "@/store/timeline/issues-timeline.store"; +import type { IIssuesTimeLineStore } from "@/store/timeline/issues-timeline.store"; +import { ModulesTimeLineStore } from "@/store/timeline/modules-timeline.store"; +import type { IModulesTimeLineStore } from "@/store/timeline/modules-timeline.store"; +import { BaseTimeLineStore } from "./base-timeline.store"; +import type { IBaseTimelineStore } from "./base-timeline.store"; export interface ITimelineStore { issuesTimeLineStore: IIssuesTimeLineStore; diff --git a/apps/web/ce/store/user/permission.store.ts b/apps/web/ce/store/user/permission.store.ts index 3a1977a92c..11ce454783 100644 --- a/apps/web/ce/store/user/permission.store.ts +++ b/apps/web/ce/store/user/permission.store.ts @@ -1,7 +1,8 @@ import { computedFn } from "mobx-utils"; -import { EUserPermissions } from "@plane/constants"; +import type { EUserPermissions } from "@plane/constants"; import type { RootStore } from "@/plane-web/store/root.store"; -import { BaseUserPermissionStore, type IBaseUserPermissionStore } from "@/store/user/base-permissions.store"; +import { BaseUserPermissionStore } from "@/store/user/base-permissions.store"; +import type { IBaseUserPermissionStore } from "@/store/user/base-permissions.store"; export type IUserPermissionStore = IBaseUserPermissionStore; diff --git a/apps/web/ce/types/pages/pane-extensions.ts b/apps/web/ce/types/pages/pane-extensions.ts index 0f64b30190..dcfae12e86 100644 --- a/apps/web/ce/types/pages/pane-extensions.ts +++ b/apps/web/ce/types/pages/pane-extensions.ts @@ -1,6 +1,6 @@ -import { - type INavigationPaneExtension as ICoreNavigationPaneExtension, - type INavigationPaneExtensionComponent, +import type { + INavigationPaneExtension as ICoreNavigationPaneExtension, + INavigationPaneExtensionComponent, } from "@/components/pages/navigation-pane"; // EE Union/map of extension data types (keyed by extension id) diff --git a/apps/web/ce/types/projects/project-activity.ts b/apps/web/ce/types/projects/project-activity.ts index bd61cf5ef3..766b0adaf2 100644 --- a/apps/web/ce/types/projects/project-activity.ts +++ b/apps/web/ce/types/projects/project-activity.ts @@ -1,4 +1,4 @@ -import { TProjectBaseActivity } from "@plane/types"; +import type { TProjectBaseActivity } from "@plane/types"; export type TProjectActivity = TProjectBaseActivity & { content: string; diff --git a/apps/web/ce/types/projects/projects.ts b/apps/web/ce/types/projects/projects.ts index 462192e262..51427282a0 100644 --- a/apps/web/ce/types/projects/projects.ts +++ b/apps/web/ce/types/projects/projects.ts @@ -1,4 +1,4 @@ -import { IPartialProject, IProject } from "@plane/types"; +import type { IPartialProject, IProject } from "@plane/types"; export type TPartialProject = IPartialProject; diff --git a/apps/web/core/components/account/auth-forms/auth-banner.tsx b/apps/web/core/components/account/auth-forms/auth-banner.tsx index da1c57c4a8..555d179af0 100644 --- a/apps/web/core/components/account/auth-forms/auth-banner.tsx +++ b/apps/web/core/components/account/auth-forms/auth-banner.tsx @@ -1,9 +1,10 @@ -import { FC } from "react"; -import { Info, X } from "lucide-react"; -// plane imports +import type { FC } from "react"; +import { Info } from "lucide-react"; import { useTranslation } from "@plane/i18n"; +import { CloseIcon } from "@plane/propel/icons"; +// plane imports // helpers -import { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; type TAuthBanner = { bannerData: TAuthErrorInfo | undefined; @@ -32,7 +33,7 @@ export const AuthBanner: FC = (props) => { onClick={() => handleBannerData?.(undefined)} aria-label={t("aria_labels.auth_forms.close_alert")} > - +

); diff --git a/apps/web/core/components/account/auth-forms/auth-header.tsx b/apps/web/core/components/account/auth-forms/auth-header.tsx index 65e6aceaf2..3ca9c38b6a 100644 --- a/apps/web/core/components/account/auth-forms/auth-header.tsx +++ b/apps/web/core/components/account/auth-forms/auth-header.tsx @@ -1,8 +1,8 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; import { useTranslation } from "@plane/i18n"; -import { IWorkspaceMemberInvitation } from "@plane/types"; +import type { IWorkspaceMemberInvitation } from "@plane/types"; // components import { LogoSpinner } from "@/components/common/logo-spinner"; import { WorkspaceLogo } from "@/components/workspace/logo"; diff --git a/apps/web/core/components/account/auth-forms/auth-root.tsx b/apps/web/core/components/account/auth-forms/auth-root.tsx index 639e329bf4..1739146472 100644 --- a/apps/web/core/components/account/auth-forms/auth-root.tsx +++ b/apps/web/core/components/account/auth-forms/auth-root.tsx @@ -1,4 +1,5 @@ -import React, { FC, useEffect, useState } from "react"; +import type { FC } from "react"; +import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; import { useSearchParams } from "next/navigation"; @@ -7,17 +8,17 @@ import { useTheme } from "next-themes"; import { API_BASE_URL } from "@plane/constants"; import { OAuthOptions } from "@plane/ui"; // 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 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 { EAuthModes, EAuthSteps, EAuthenticationErrorCodes, EErrorAlertType, - TAuthErrorInfo, authErrorHandler, } from "@/helpers/authentication.helper"; // hooks diff --git a/apps/web/core/components/account/auth-forms/email.tsx b/apps/web/core/components/account/auth-forms/email.tsx index d9ee4ccc90..97a01dfbaf 100644 --- a/apps/web/core/components/account/auth-forms/email.tsx +++ b/apps/web/core/components/account/auth-forms/email.tsx @@ -1,13 +1,14 @@ "use client"; -import { FC, FormEvent, useMemo, useRef, useState } from "react"; +import type { FC, FormEvent } from "react"; +import { useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; // icons import { CircleAlert, XCircle } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; -import { IEmailCheckData } from "@plane/types"; +import type { IEmailCheckData } from "@plane/types"; import { Input, Spinner } from "@plane/ui"; import { cn, checkEmailValidity } from "@plane/utils"; // helpers diff --git a/apps/web/core/components/account/auth-forms/forgot-password-popover.tsx b/apps/web/core/components/account/auth-forms/forgot-password-popover.tsx index 3b243b8942..3e47dbe1c7 100644 --- a/apps/web/core/components/account/auth-forms/forgot-password-popover.tsx +++ b/apps/web/core/components/account/auth-forms/forgot-password-popover.tsx @@ -1,9 +1,9 @@ import { Fragment, useState } from "react"; import { usePopper } from "react-popper"; -import { X } from "lucide-react"; import { Popover } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; +import { CloseIcon } from "@plane/propel/icons"; export const ForgotPasswordPopover = () => { // popper-js refs @@ -51,7 +51,7 @@ export const ForgotPasswordPopover = () => { onClick={() => close()} aria-label={t("aria_labels.auth_forms.close_popover")} > - +
)} diff --git a/apps/web/core/components/account/auth-forms/form-root.tsx b/apps/web/core/components/account/auth-forms/form-root.tsx index d23afbfcc3..4edbcc0458 100644 --- a/apps/web/core/components/account/auth-forms/form-root.tsx +++ b/apps/web/core/components/account/auth-forms/form-root.tsx @@ -4,9 +4,10 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import { EAuthModes, EAuthSteps } from "@plane/constants"; -import { IEmailCheckData } from "@plane/types"; +import type { IEmailCheckData } from "@plane/types"; // helpers -import { authErrorHandler, TAuthErrorInfo } from "@/helpers/authentication.helper"; +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { authErrorHandler } from "@/helpers/authentication.helper"; // hooks import { useInstance } from "@/hooks/store/use-instance"; import { useAppRouter } from "@/hooks/use-app-router"; diff --git a/apps/web/core/components/account/auth-forms/password.tsx b/apps/web/core/components/account/auth-forms/password.tsx index 52d32dc5d4..5fb6a4276d 100644 --- a/apps/web/core/components/account/auth-forms/password.tsx +++ b/apps/web/core/components/account/auth-forms/password.tsx @@ -4,11 +4,12 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; // icons -import { Eye, EyeOff, Info, X, XCircle } from "lucide-react"; +import { Eye, EyeOff, Info, XCircle } from "lucide-react"; // plane imports import { API_BASE_URL, E_PASSWORD_STRENGTH, AUTH_TRACKER_EVENTS, AUTH_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; +import { CloseIcon } from "@plane/propel/icons"; import { Input, PasswordStrengthIndicator, Spinner } from "@plane/ui"; import { getPasswordStrength } from "@plane/utils"; // components @@ -134,7 +135,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-red-500/20 text-custom-primary-100/80" onClick={() => setBannerMessage(false)} > - +
)} diff --git a/apps/web/core/components/account/auth-forms/reset-password.tsx b/apps/web/core/components/account/auth-forms/reset-password.tsx index b80a941cb3..ffe40d7e6e 100644 --- a/apps/web/core/components/account/auth-forms/reset-password.tsx +++ b/apps/web/core/components/account/auth-forms/reset-password.tsx @@ -13,12 +13,8 @@ import { Input, PasswordStrengthIndicator } from "@plane/ui"; // components import { getPasswordStrength } from "@plane/utils"; // helpers -import { - EAuthenticationErrorCodes, - EErrorAlertType, - TAuthErrorInfo, - authErrorHandler, -} from "@/helpers/authentication.helper"; +import type { EAuthenticationErrorCodes, TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { EErrorAlertType, authErrorHandler } from "@/helpers/authentication.helper"; // services import { AuthService } from "@/services/auth.service"; // local imports diff --git a/apps/web/core/components/account/auth-forms/set-password.tsx b/apps/web/core/components/account/auth-forms/set-password.tsx index c06eec0769..a84ee913d7 100644 --- a/apps/web/core/components/account/auth-forms/set-password.tsx +++ b/apps/web/core/components/account/auth-forms/set-password.tsx @@ -1,6 +1,7 @@ "use client"; -import { FormEvent, useEffect, useMemo, useState } from "react"; +import type { FormEvent } from "react"; +import { useEffect, useMemo, useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; // icons diff --git a/apps/web/core/components/analytics/empty-state.tsx b/apps/web/core/components/analytics/empty-state.tsx index 3704f3e846..5cc27ba3ab 100644 --- a/apps/web/core/components/analytics/empty-state.tsx +++ b/apps/web/core/components/analytics/empty-state.tsx @@ -1,8 +1,11 @@ import React from "react"; import Image from "next/image"; +import { useTheme } from "next-themes"; // plane package imports import { cn } from "@plane/utils"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +// assets +import darkBackgroundAsset from "@/app/assets/empty-state/analytics/empty-grid-background-dark.webp?url"; +import lightBackgroundAsset from "@/app/assets/empty-state/analytics/empty-grid-background-light.webp?url"; type Props = { title: string; @@ -12,7 +15,9 @@ type Props = { }; const AnalyticsEmptyState = ({ title, description, assetPath, className }: Props) => { - const backgroundReolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-grid-background" }); + // theme hook + const { resolvedTheme } = useTheme(); + const backgroundReolvedPath = resolvedTheme === "light" ? lightBackgroundAsset : darkBackgroundAsset; return (
diff --git a/apps/web/core/components/analytics/insight-card.tsx b/apps/web/core/components/analytics/insight-card.tsx index abfbdf8b95..115cdd2c68 100644 --- a/apps/web/core/components/analytics/insight-card.tsx +++ b/apps/web/core/components/analytics/insight-card.tsx @@ -1,6 +1,6 @@ // plane package imports import React from "react"; -import { IAnalyticsResponseFields } from "@plane/types"; +import type { IAnalyticsResponseFields } from "@plane/types"; import { Loader } from "@plane/ui"; export type InsightCardProps = { diff --git a/apps/web/core/components/analytics/insight-table/data-table.tsx b/apps/web/core/components/analytics/insight-table/data-table.tsx index 8b1c1bab28..8c64687939 100644 --- a/apps/web/core/components/analytics/insight-table/data-table.tsx +++ b/apps/web/core/components/analytics/insight-table/data-table.tsx @@ -1,29 +1,30 @@ "use client"; import * as React from "react"; -import { +import type { ColumnDef, ColumnFiltersState, SortingState, VisibilityState, Table as TanstackTable, +} from "@tanstack/react-table"; +import { flexRender, getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, - getPaginationRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; -import { Search, X } from "lucide-react"; -// plane package imports +import { Search } from "lucide-react"; import { useTranslation } from "@plane/i18n"; +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import { CloseIcon } from "@plane/propel/icons"; +// plane package imports import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table"; import { cn } from "@plane/utils"; // plane web components -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; -import AnalyticsEmptyState from "../empty-state"; interface DataTableProps { columns: ColumnDef[]; @@ -40,7 +41,6 @@ export function DataTable({ columns, data, searchPlaceholder, act const { t } = useTranslation(); const inputRef = React.useRef(null); const [isSearchOpen, setIsSearchOpen] = React.useState(false); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-table" }); const table = useReactTable({ data, @@ -117,7 +117,7 @@ export function DataTable({ columns, data, searchPlaceholder, act setIsSearchOpen(false); }} > - + )}
@@ -154,14 +154,12 @@ export function DataTable({ columns, data, searchPlaceholder, act ) : ( -
- -
+
)} diff --git a/apps/web/core/components/analytics/insight-table/loader.tsx b/apps/web/core/components/analytics/insight-table/loader.tsx index 0f7f9dc358..0ccff9a9b2 100644 --- a/apps/web/core/components/analytics/insight-table/loader.tsx +++ b/apps/web/core/components/analytics/insight-table/loader.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ColumnDef } from "@tanstack/react-table"; +import type { ColumnDef } from "@tanstack/react-table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table"; import { Loader } from "@plane/ui"; diff --git a/apps/web/core/components/analytics/insight-table/root.tsx b/apps/web/core/components/analytics/insight-table/root.tsx index 4b376dee6a..4653534968 100644 --- a/apps/web/core/components/analytics/insight-table/root.tsx +++ b/apps/web/core/components/analytics/insight-table/root.tsx @@ -1,8 +1,8 @@ -import { ColumnDef, Row, Table } from "@tanstack/react-table"; +import type { ColumnDef, Row, Table } from "@tanstack/react-table"; import { Download } from "lucide-react"; import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; -import { AnalyticsTableDataMap, TAnalyticsTabsBase } from "@plane/types"; +import type { AnalyticsTableDataMap, TAnalyticsTabsBase } from "@plane/types"; import { DataTable } from "./data-table"; import { TableLoader } from "./loader"; interface InsightTableProps> { diff --git a/apps/web/core/components/analytics/overview/project-insights.tsx b/apps/web/core/components/analytics/overview/project-insights.tsx index 994e6e9b76..56a87b5bec 100644 --- a/apps/web/core/components/analytics/overview/project-insights.tsx +++ b/apps/web/core/components/analytics/overview/project-insights.tsx @@ -1,21 +1,20 @@ +import { lazy, Suspense } from "react"; import { observer } from "mobx-react"; -import dynamic from "next/dynamic"; import { useParams } from "next/navigation"; import useSWR from "swr"; // plane package imports import { useTranslation } from "@plane/i18n"; -import { TChartData } from "@plane/types"; +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import type { TChartData } from "@plane/types"; // hooks import { useAnalytics } from "@/hooks/store/use-analytics"; // services -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { AnalyticsService } from "@/services/analytics.service"; // plane web components import AnalyticsSectionWrapper from "../analytics-section-wrapper"; -import AnalyticsEmptyState from "../empty-state"; import { ProjectInsightsLoader } from "../loaders"; -const RadarChart = dynamic(() => +const RadarChart = lazy(() => import("@plane/propel/charts/radar-chart").then((mod) => ({ default: mod.RadarChart, })) @@ -29,7 +28,6 @@ const ProjectInsights = observer(() => { const workspaceSlug = params.workspaceSlug.toString(); const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } = useAnalytics(); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-radar" }); const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR( `radar-chart-project-insights-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`, @@ -56,38 +54,40 @@ const ProjectInsights = observer(() => { {isLoadingProjectInsight ? ( ) : projectInsightsData && projectInsightsData?.length == 0 ? ( - ) : (
{projectInsightsData && ( - }> + + ]} + margin={{ top: 0, right: 40, bottom: 10, left: 40 }} + showTooltip + angleAxis={{ + key: "name", + }} + /> + )}
{t("workspace_analytics.summary_of_projects")}
diff --git a/apps/web/core/components/analytics/select/analytics-params.tsx b/apps/web/core/components/analytics/select/analytics-params.tsx index edcebf78b2..5e9535da23 100644 --- a/apps/web/core/components/analytics/select/analytics-params.tsx +++ b/apps/web/core/components/analytics/select/analytics-params.tsx @@ -1,10 +1,12 @@ import { useMemo } from "react"; import { observer } from "mobx-react"; -import { Control, Controller, UseFormSetValue } from "react-hook-form"; +import type { Control, UseFormSetValue } from "react-hook-form"; +import { Controller } from "react-hook-form"; import { Calendar, SlidersHorizontal } from "lucide-react"; // plane package imports import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "@plane/constants"; -import { ChartYAxisMetric, IAnalyticsParams } from "@plane/types"; +import type { IAnalyticsParams } from "@plane/types"; +import { ChartYAxisMetric } from "@plane/types"; import { cn } from "@plane/utils"; // plane web components import { SelectXAxis } from "./select-x-axis"; diff --git a/apps/web/core/components/analytics/select/duration.tsx b/apps/web/core/components/analytics/select/duration.tsx index 5c99a61b0e..f668f3c472 100644 --- a/apps/web/core/components/analytics/select/duration.tsx +++ b/apps/web/core/components/analytics/select/duration.tsx @@ -1,12 +1,13 @@ // plane package imports -import React, { ReactNode } from "react"; +import type { ReactNode } from "react"; +import React from "react"; import { Calendar } from "lucide-react"; // plane package imports import { ANALYTICS_DURATION_FILTER_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { CustomSearchSelect } from "@plane/ui"; // types -import { TDropdownProps } from "@/components/dropdowns/types"; +import type { TDropdownProps } from "@/components/dropdowns/types"; type Props = TDropdownProps & { value: string | null; diff --git a/apps/web/core/components/analytics/select/select-x-axis.tsx b/apps/web/core/components/analytics/select/select-x-axis.tsx index 9fc846c30b..041fc8feec 100644 --- a/apps/web/core/components/analytics/select/select-x-axis.tsx +++ b/apps/web/core/components/analytics/select/select-x-axis.tsx @@ -1,6 +1,6 @@ "use client"; // plane package imports -import { ChartXAxisProperty } from "@plane/types"; +import type { ChartXAxisProperty } from "@plane/types"; import { CustomSelect } from "@plane/ui"; type Props = { diff --git a/apps/web/core/components/analytics/select/select-y-axis.tsx b/apps/web/core/components/analytics/select/select-y-axis.tsx index 5ed58311bd..2295f06322 100644 --- a/apps/web/core/components/analytics/select/select-y-axis.tsx +++ b/apps/web/core/components/analytics/select/select-y-axis.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { EEstimateSystem } from "@plane/constants"; import { ProjectIcon } from "@plane/propel/icons"; -import { ChartYAxisMetric } from "@plane/types"; +import type { ChartYAxisMetric } from "@plane/types"; // plane package imports import { CustomSelect } from "@plane/ui"; // hooks diff --git a/apps/web/core/components/analytics/total-insights.tsx b/apps/web/core/components/analytics/total-insights.tsx index a822e75600..5dfc6bb998 100644 --- a/apps/web/core/components/analytics/total-insights.tsx +++ b/apps/web/core/components/analytics/total-insights.tsx @@ -2,9 +2,10 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; -import { IInsightField, ANALYTICS_INSIGHTS_FIELDS } from "@plane/constants"; +import type { IInsightField } from "@plane/constants"; +import { ANALYTICS_INSIGHTS_FIELDS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IAnalyticsResponse, TAnalyticsTabsBase } from "@plane/types"; +import type { IAnalyticsResponse, TAnalyticsTabsBase } from "@plane/types"; import { cn } from "@plane/utils"; // hooks import { useAnalytics } from "@/hooks/store/use-analytics"; diff --git a/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx b/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx index 5db79a8158..f92c42d048 100644 --- a/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx +++ b/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx @@ -5,16 +5,15 @@ import useSWR from "swr"; // plane package imports import { useTranslation } from "@plane/i18n"; import { AreaChart } from "@plane/propel/charts/area-chart"; -import { IChartResponse, TChartData } from "@plane/types"; +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import type { IChartResponse, TChartData } from "@plane/types"; import { renderFormattedDate } from "@plane/utils"; // hooks import { useAnalytics } from "@/hooks/store/use-analytics"; // services -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { AnalyticsService } from "@/services/analytics.service"; // plane web components import AnalyticsSectionWrapper from "../analytics-section-wrapper"; -import AnalyticsEmptyState from "../empty-state"; import { ChartLoader } from "../loaders"; const analyticsService = new AnalyticsService(); @@ -31,7 +30,6 @@ const CreatedVsResolved = observer(() => { const params = useParams(); const { t } = useTranslation(); const workspaceSlug = params.workspaceSlug.toString(); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-area" }); const { data: createdVsResolvedData, isLoading: isCreatedVsResolvedLoading } = useSWR( `created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}-${isEpic}`, () => @@ -121,11 +119,11 @@ const CreatedVsResolved = observer(() => { }} /> ) : ( - )} diff --git a/apps/web/core/components/analytics/work-items/customized-insights.tsx b/apps/web/core/components/analytics/work-items/customized-insights.tsx index 42fa6e84ca..22c698b0cc 100644 --- a/apps/web/core/components/analytics/work-items/customized-insights.tsx +++ b/apps/web/core/components/analytics/work-items/customized-insights.tsx @@ -3,7 +3,8 @@ import { useParams } from "next/navigation"; import { useForm } from "react-hook-form"; // plane package imports import { useTranslation } from "@plane/i18n"; -import { ChartXAxisProperty, ChartYAxisMetric, IAnalyticsParams } from "@plane/types"; +import type { IAnalyticsParams } from "@plane/types"; +import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/types"; import { cn } from "@plane/utils"; // plane web components import AnalyticsSectionWrapper from "../analytics-section-wrapper"; diff --git a/apps/web/core/components/analytics/work-items/modal/content.tsx b/apps/web/core/components/analytics/work-items/modal/content.tsx index 3f590b1123..d13de2a782 100644 --- a/apps/web/core/components/analytics/work-items/modal/content.tsx +++ b/apps/web/core/components/analytics/work-items/modal/content.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { Tab } from "@headlessui/react"; // plane package imports -import { ICycle, IModule, IProject } from "@plane/types"; +import type { ICycle, IModule, IProject } from "@plane/types"; import { Spinner } from "@plane/ui"; // hooks import { useAnalytics } from "@/hooks/store/use-analytics"; diff --git a/apps/web/core/components/analytics/work-items/modal/header.tsx b/apps/web/core/components/analytics/work-items/modal/header.tsx index 1aa2c1b665..22f1a14c38 100644 --- a/apps/web/core/components/analytics/work-items/modal/header.tsx +++ b/apps/web/core/components/analytics/work-items/modal/header.tsx @@ -1,7 +1,8 @@ import { observer } from "mobx-react"; // plane package imports -import { Expand, Shrink, X } from "lucide-react"; -import { ICycle, IModule } from "@plane/types"; +import { Expand, Shrink } from "lucide-react"; +import { CloseIcon } from "@plane/propel/icons"; +import type { ICycle, IModule } from "@plane/types"; // icons type Props = { @@ -34,7 +35,7 @@ export const WorkItemsModalHeader: React.FC = observer((props) => { className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100" onClick={handleClose} > - +
diff --git a/apps/web/core/components/analytics/work-items/modal/index.tsx b/apps/web/core/components/analytics/work-items/modal/index.tsx index 8c3ccc8da3..129dd5f5e5 100644 --- a/apps/web/core/components/analytics/work-items/modal/index.tsx +++ b/apps/web/core/components/analytics/work-items/modal/index.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; // plane package imports import { ModalPortal, EPortalWidth, EPortalPosition } from "@plane/propel/portal"; -import { ICycle, IModule, IProject } from "@plane/types"; +import type { ICycle, IModule, IProject } from "@plane/types"; import { useAnalytics } from "@/hooks/store/use-analytics"; // plane web components import { WorkItemsModalMainContent } from "./content"; diff --git a/apps/web/core/components/analytics/work-items/priority-chart.tsx b/apps/web/core/components/analytics/work-items/priority-chart.tsx index 8fdf94dacb..4fb0304d69 100644 --- a/apps/web/core/components/analytics/work-items/priority-chart.tsx +++ b/apps/web/core/components/analytics/work-items/priority-chart.tsx @@ -1,30 +1,24 @@ import { useMemo } from "react"; -import { ColumnDef, Row, RowData, Table } from "@tanstack/react-table"; +import type { ColumnDef, Row, RowData, Table } from "@tanstack/react-table"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { useTheme } from "next-themes"; import useSWR from "swr"; // plane package imports import { Download } from "lucide-react"; -import { - ANALYTICS_X_AXIS_VALUES, - ANALYTICS_Y_AXIS_VALUES, - CHART_COLOR_PALETTES, - ChartXAxisDateGrouping, - EChartModels, -} from "@plane/constants"; +import type { ChartXAxisDateGrouping } from "@plane/constants"; +import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, CHART_COLOR_PALETTES, EChartModels } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { BarChart } from "@plane/propel/charts/bar-chart"; -import { TBarItem, TChart, TChartDatum, ChartXAxisProperty, ChartYAxisMetric } from "@plane/types"; +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import type { TBarItem, TChart, TChartDatum, ChartXAxisProperty, ChartYAxisMetric } from "@plane/types"; // plane web components import { generateExtendedColors, parseChartData } from "@/components/chart/utils"; // hooks import { useAnalytics } from "@/hooks/store/use-analytics"; import { useProjectState } from "@/hooks/store/use-project-state"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { AnalyticsService } from "@/services/analytics.service"; -import AnalyticsEmptyState from "../empty-state"; import { exportCSV } from "../export"; import { DataTable } from "../insight-table/data-table"; import { ChartLoader } from "../loaders"; @@ -51,7 +45,6 @@ const analyticsService = new AnalyticsService(); const PriorityChart = observer((props: Props) => { const { x_axis, y_axis, group_by } = props; const { t } = useTranslation(); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-bar" }); // store hooks const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics(); const { workspaceStates } = useProjectState(); @@ -237,11 +230,11 @@ const PriorityChart = observer((props: Props) => { /> ) : ( - )}
diff --git a/apps/web/core/components/analytics/work-items/utils.ts b/apps/web/core/components/analytics/work-items/utils.ts index 37c74ec812..613fa6b620 100644 --- a/apps/web/core/components/analytics/work-items/utils.ts +++ b/apps/web/core/components/analytics/work-items/utils.ts @@ -1,5 +1,6 @@ // plane package imports -import { ChartXAxisProperty, ChartYAxisMetric, IState } from "@plane/types"; +import type { ChartYAxisMetric, IState } from "@plane/types"; +import { ChartXAxisProperty } from "@plane/types"; interface ParamsProps { x_axis: ChartXAxisProperty; diff --git a/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx b/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx index f99fd22bcb..0d0d69cb1b 100644 --- a/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx +++ b/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { ColumnDef, Row, RowData } from "@tanstack/react-table"; +import type { ColumnDef, Row, RowData } from "@tanstack/react-table"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; @@ -7,7 +7,7 @@ import { UserRound } from "lucide-react"; import { useTranslation } from "@plane/i18n"; import { ProjectIcon } from "@plane/propel/icons"; // plane package imports -import { AnalyticsTableDataMap, WorkItemInsightColumns } from "@plane/types"; +import type { AnalyticsTableDataMap, WorkItemInsightColumns } from "@plane/types"; // plane web components import { Avatar } from "@plane/ui"; import { getFileURL } from "@plane/utils"; diff --git a/apps/web/core/components/api-token/delete-token-modal.tsx b/apps/web/core/components/api-token/delete-token-modal.tsx index 2bac04bdb2..22b5f5dd62 100644 --- a/apps/web/core/components/api-token/delete-token-modal.tsx +++ b/apps/web/core/components/api-token/delete-token-modal.tsx @@ -1,13 +1,14 @@ "use client"; -import { useState, FC } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { mutate } from "swr"; // types import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { APITokenService } from "@plane/services"; -import { IApiToken } from "@plane/types"; +import type { IApiToken } from "@plane/types"; // ui import { AlertModalCore } from "@plane/ui"; // fetch-keys diff --git a/apps/web/core/components/api-token/empty-state.tsx b/apps/web/core/components/api-token/empty-state.tsx index 194966df5d..fafdef3592 100644 --- a/apps/web/core/components/api-token/empty-state.tsx +++ b/apps/web/core/components/api-token/empty-state.tsx @@ -5,7 +5,7 @@ import Image from "next/image"; // ui import { Button } from "@plane/propel/button"; // assets -import emptyApiTokens from "@/public/empty-state/api-token.svg"; +import emptyApiTokens from "@/app/assets/empty-state/api-token.svg?url"; type Props = { onClick: () => void; diff --git a/apps/web/core/components/api-token/modal/create-token-modal.tsx b/apps/web/core/components/api-token/modal/create-token-modal.tsx index 46075cb8f6..ac27873e93 100644 --- a/apps/web/core/components/api-token/modal/create-token-modal.tsx +++ b/apps/web/core/components/api-token/modal/create-token-modal.tsx @@ -6,7 +6,7 @@ import { mutate } from "swr"; import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { APITokenService } from "@plane/services"; -import { IApiToken } from "@plane/types"; +import type { IApiToken } from "@plane/types"; import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; import { renderFormattedDate, csvDownload } from "@plane/utils"; // constants diff --git a/apps/web/core/components/api-token/modal/form.tsx b/apps/web/core/components/api-token/modal/form.tsx index 84fda20fa7..a39636e902 100644 --- a/apps/web/core/components/api-token/modal/form.tsx +++ b/apps/web/core/components/api-token/modal/form.tsx @@ -8,7 +8,7 @@ import { Calendar } from "lucide-react"; import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { IApiToken } from "@plane/types"; +import type { IApiToken } from "@plane/types"; // ui import { CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui"; import { cn, renderFormattedDate, renderFormattedTime } from "@plane/utils"; diff --git a/apps/web/core/components/api-token/modal/generated-token-details.tsx b/apps/web/core/components/api-token/modal/generated-token-details.tsx index 36124315e8..a2a4dff78e 100644 --- a/apps/web/core/components/api-token/modal/generated-token-details.tsx +++ b/apps/web/core/components/api-token/modal/generated-token-details.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import { IApiToken } from "@plane/types"; +import type { IApiToken } from "@plane/types"; // ui import { renderFormattedDate, renderFormattedTime, copyTextToClipboard } from "@plane/utils"; // helpers diff --git a/apps/web/core/components/api-token/token-list-item.tsx b/apps/web/core/components/api-token/token-list-item.tsx index 165ca07189..46ad6d9f1f 100644 --- a/apps/web/core/components/api-token/token-list-item.tsx +++ b/apps/web/core/components/api-token/token-list-item.tsx @@ -5,7 +5,7 @@ import { XCircle } from "lucide-react"; // plane imports import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; import { Tooltip } from "@plane/propel/tooltip"; -import { IApiToken } from "@plane/types"; +import type { IApiToken } from "@plane/types"; import { renderFormattedDate, calculateTimeAgo, renderFormattedTime } from "@plane/utils"; // components import { DeleteApiTokenModal } from "@/components/api-token/delete-token-modal"; diff --git a/apps/web/core/components/archives/archive-tabs-list.tsx b/apps/web/core/components/archives/archive-tabs-list.tsx index 432fac68de..db94d191a8 100644 --- a/apps/web/core/components/archives/archive-tabs-list.tsx +++ b/apps/web/core/components/archives/archive-tabs-list.tsx @@ -1,9 +1,9 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; // types -import { IProject } from "@plane/types"; +import type { IProject } from "@plane/types"; // hooks import { useProject } from "@/hooks/store/use-project"; diff --git a/apps/web/core/components/auth-screens/auth-base.tsx b/apps/web/core/components/auth-screens/auth-base.tsx index f5624f07bd..a6bd3d6318 100644 --- a/apps/web/core/components/auth-screens/auth-base.tsx +++ b/apps/web/core/components/auth-screens/auth-base.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; import { AuthRoot } from "@/components/account/auth-forms/auth-root"; -import { EAuthModes } from "@/helpers/authentication.helper"; +import type { EAuthModes } from "@/helpers/authentication.helper"; import { AuthFooter } from "./footer"; import { AuthHeader } from "./header"; diff --git a/apps/web/core/components/auth-screens/not-authorized-view.tsx b/apps/web/core/components/auth-screens/not-authorized-view.tsx index 58265a41fb..3156d4a977 100644 --- a/apps/web/core/components/auth-screens/not-authorized-view.tsx +++ b/apps/web/core/components/auth-screens/not-authorized-view.tsx @@ -1,12 +1,12 @@ import React from "react"; import { observer } from "mobx-react"; import Image from "next/image"; +// assets +import ProjectNotAuthorizedImg from "@/app/assets/auth/project-not-authorized.svg?url"; +import Unauthorized from "@/app/assets/auth/unauthorized.svg?url"; +import WorkspaceNotAuthorizedImg from "@/app/assets/auth/workspace-not-authorized.svg?url"; // layouts import DefaultLayout from "@/layouts/default-layout"; -// images -import ProjectNotAuthorizedImg from "@/public/auth/project-not-authorized.svg"; -import Unauthorized from "@/public/auth/unauthorized.svg"; -import WorkspaceNotAuthorizedImg from "@/public/auth/workspace-not-authorized.svg"; type Props = { actionButton?: React.ReactNode; diff --git a/apps/web/core/components/auth-screens/project/join-project.tsx b/apps/web/core/components/auth-screens/project/join-project.tsx index 39fbaba359..1e10e2ec80 100644 --- a/apps/web/core/components/auth-screens/project/join-project.tsx +++ b/apps/web/core/components/auth-screens/project/join-project.tsx @@ -5,11 +5,11 @@ import { useParams } from "next/navigation"; import { ClipboardList } from "lucide-react"; // plane imports import { Button } from "@plane/propel/button"; +// assets +import Unauthorized from "@/app/assets/auth/unauthorized.svg?url"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; -// assets -import Unauthorized from "@/public/auth/unauthorized.svg"; type Props = { projectId?: string; diff --git a/apps/web/core/components/automation/auto-archive-automation.tsx b/apps/web/core/components/automation/auto-archive-automation.tsx index e346a36fe9..445a9d8d0c 100644 --- a/apps/web/core/components/automation/auto-archive-automation.tsx +++ b/apps/web/core/components/automation/auto-archive-automation.tsx @@ -13,7 +13,7 @@ import { PROJECT_SETTINGS_TRACKER_EVENTS, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IProject } from "@plane/types"; +import type { IProject } from "@plane/types"; // ui import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui"; // component diff --git a/apps/web/core/components/automation/auto-close-automation.tsx b/apps/web/core/components/automation/auto-close-automation.tsx index d223ddf3cd..19e294a22d 100644 --- a/apps/web/core/components/automation/auto-close-automation.tsx +++ b/apps/web/core/components/automation/auto-close-automation.tsx @@ -15,8 +15,8 @@ import { PROJECT_SETTINGS_TRACKER_EVENTS, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { StateGroupIcon, DoubleCircleIcon } from "@plane/propel/icons"; -import { IProject } from "@plane/types"; +import { StateGroupIcon, StatePropertyIcon } from "@plane/propel/icons"; +import type { IProject } from "@plane/types"; // ui import { CustomSelect, CustomSearchSelect, ToggleSwitch, Loader } from "@plane/ui"; // component @@ -188,7 +188,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => { size={EIconSize.LG} /> ) : ( - + )} {selectedOption?.name ? selectedOption.name diff --git a/apps/web/core/components/base-layouts/constants.ts b/apps/web/core/components/base-layouts/constants.ts new file mode 100644 index 0000000000..60e45a3721 --- /dev/null +++ b/apps/web/core/components/base-layouts/constants.ts @@ -0,0 +1,15 @@ +import { BoardLayoutIcon, ListLayoutIcon } from "@plane/propel/icons"; +import type { IBaseLayoutConfig } from "@plane/types"; + +export const BASE_LAYOUTS: IBaseLayoutConfig[] = [ + { + key: "list", + icon: ListLayoutIcon, + label: "List Layout", + }, + { + key: "kanban", + icon: BoardLayoutIcon, + label: "Board Layout", + }, +]; diff --git a/apps/web/core/components/base-layouts/hooks/use-group-drop-target.ts b/apps/web/core/components/base-layouts/hooks/use-group-drop-target.ts new file mode 100644 index 0000000000..5fa232092c --- /dev/null +++ b/apps/web/core/components/base-layouts/hooks/use-group-drop-target.ts @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from "react"; +import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; + +interface UseGroupDropTargetProps { + groupId: string; + enableDragDrop?: boolean; + onDrop?: (itemId: string, targetId: string | null, sourceGroupId: string, targetGroupId: string) => void; +} + +interface DragSourceData { + id: string; + groupId: string; + type: "ITEM" | "GROUP"; +} + +/** + * A hook that turns an element into a valid drop target for group drag-and-drop. + * + * @returns groupRef (attach to the droppable container) and isDraggingOver (for visual feedback) + */ +export const useGroupDropTarget = ({ groupId, enableDragDrop = false, onDrop }: UseGroupDropTargetProps) => { + const groupRef = useRef(null); + const [isDraggingOver, setIsDraggingOver] = useState(false); + + useEffect(() => { + const element = groupRef.current; + if (!element || !enableDragDrop || !onDrop) return; + + const cleanup = dropTargetForElements({ + element, + getData: () => ({ groupId, type: "GROUP" }), + + canDrop: ({ source }) => { + const data = (source?.data || {}) as Partial; + return data.type === "ITEM" && !!data.groupId && data.groupId !== groupId; + }, + + onDragEnter: () => setIsDraggingOver(true), + onDragLeave: () => setIsDraggingOver(false), + + onDrop: ({ source }) => { + setIsDraggingOver(false); + const data = (source?.data || {}) as Partial; + if (data.type !== "ITEM" || !data.id || !data.groupId) return; + if (data.groupId !== groupId) { + onDrop(data.id, null, data.groupId, groupId); + } + }, + }); + + return cleanup; + }, [groupId, enableDragDrop, onDrop]); + + return { groupRef, isDraggingOver }; +}; diff --git a/apps/web/core/components/base-layouts/hooks/use-layout-state.ts b/apps/web/core/components/base-layouts/hooks/use-layout-state.ts new file mode 100644 index 0000000000..8d8bc75b27 --- /dev/null +++ b/apps/web/core/components/base-layouts/hooks/use-layout-state.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; + +type UseLayoutStateProps = + | { + mode: "external"; + externalCollapsedGroups: string[]; + externalOnToggleGroup: (groupId: string) => void; + enableAutoScroll?: boolean; + } + | { + mode?: "internal"; + enableAutoScroll?: boolean; + }; + +/** + * Hook for managing layout state including: + * - Collapsed/expanded group tracking (internal or external) + * - Auto-scroll setup for drag-and-drop + */ +export const useLayoutState = (props: UseLayoutStateProps = { mode: "internal" }) => { + const containerRef = useRef(null); + + // Internal fallback state + const [internalCollapsedGroups, setInternalCollapsedGroups] = useState([]); + + // Stable internal toggle function + const internalToggleGroup = useCallback((groupId: string) => { + setInternalCollapsedGroups((prev) => + prev.includes(groupId) ? prev.filter((id) => id !== groupId) : [...prev, groupId] + ); + }, []); + + const useExternal = props.mode === "external"; + const collapsedGroups = useExternal ? props.externalCollapsedGroups : internalCollapsedGroups; + const onToggleGroup = useExternal ? props.externalOnToggleGroup : internalToggleGroup; + + // Enable auto-scroll for DnD + useEffect(() => { + const element = containerRef.current; + if (!element || !props.enableAutoScroll) return; + + const cleanup = combine( + autoScrollForElements({ + element, + }) + ); + + return cleanup; + }, [props.enableAutoScroll]); + + return { + containerRef, + collapsedGroups, + onToggleGroup, + }; +}; diff --git a/apps/web/core/components/base-layouts/kanban/group-header.tsx b/apps/web/core/components/base-layouts/kanban/group-header.tsx new file mode 100644 index 0000000000..be84879957 --- /dev/null +++ b/apps/web/core/components/base-layouts/kanban/group-header.tsx @@ -0,0 +1,14 @@ +import type { IGroupHeaderProps } from "@plane/types"; + +export const GroupHeader = ({ group, itemCount, onToggleGroup }: IGroupHeaderProps) => ( + +); diff --git a/apps/web/core/components/base-layouts/kanban/group.tsx b/apps/web/core/components/base-layouts/kanban/group.tsx new file mode 100644 index 0000000000..2cd291897f --- /dev/null +++ b/apps/web/core/components/base-layouts/kanban/group.tsx @@ -0,0 +1,96 @@ +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +import type { IBaseLayoutsKanbanItem, IBaseLayoutsKanbanGroupProps } from "@plane/types"; +import { cn } from "@plane/utils"; +import { useGroupDropTarget } from "../hooks/use-group-drop-target"; +import { GroupHeader } from "./group-header"; +import { BaseKanbanItem } from "./item"; + +export const BaseKanbanGroup = observer((props: IBaseLayoutsKanbanGroupProps) => { + const { + group, + itemIds, + items, + renderItem, + renderGroupHeader, + isCollapsed, + onToggleGroup, + enableDragDrop = false, + onDrop, + canDrag, + groupClassName, + loadMoreItems: _loadMoreItems, + } = props; + + const { t } = useTranslation(); + const { groupRef, isDraggingOver } = useGroupDropTarget({ + groupId: group.id, + enableDragDrop, + onDrop, + }); + + return ( +
+ {/* Group Header */} +
+ {renderGroupHeader ? ( + renderGroupHeader({ group, itemCount: itemIds.length, isCollapsed, onToggleGroup }) + ) : ( + + )} +
+ + {/* Group Items */} + {!isCollapsed && ( +
+ {itemIds.map((itemId, index) => { + const item = items[itemId]; + if (!item) return null; + + return ( + + ); + })} + + {itemIds.length === 0 && ( +
+ {t("common.no_items_in_this_group")} +
+ )} +
+ )} + + {isDraggingOver && enableDragDrop && ( +
+
+ {t("common.drop_here_to_move")} +
+
+ )} +
+ ); +}); diff --git a/apps/web/core/components/base-layouts/kanban/item.tsx b/apps/web/core/components/base-layouts/kanban/item.tsx new file mode 100644 index 0000000000..325e6f0d68 --- /dev/null +++ b/apps/web/core/components/base-layouts/kanban/item.tsx @@ -0,0 +1,40 @@ +import { useEffect, useRef } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { observer } from "mobx-react"; +import type { IBaseLayoutsKanbanItem, IBaseLayoutsKanbanItemProps } from "@plane/types"; + +export const BaseKanbanItem = observer((props: IBaseLayoutsKanbanItemProps) => { + const { item, groupId, renderItem, enableDragDrop, canDrag } = props; + + const itemRef = useRef(null); + + const isDragAllowed = canDrag ? canDrag(item) : true; + + // Setup draggable and drop target + useEffect(() => { + const element = itemRef.current; + if (!element || !enableDragDrop) return; + + return combine( + draggable({ + element, + canDrag: () => isDragAllowed, + getInitialData: () => ({ id: item.id, type: "ITEM", groupId }), + }), + dropTargetForElements({ + element, + getData: () => ({ id: item.id, groupId, type: "ITEM" }), + canDrop: ({ source }) => source?.data?.id !== item.id, + }) + ); + }, [enableDragDrop, isDragAllowed, item.id, groupId]); + + const renderedItem = renderItem(item, groupId); + + return ( +
+ {renderedItem} +
+ ); +}); diff --git a/apps/web/core/components/base-layouts/kanban/layout.tsx b/apps/web/core/components/base-layouts/kanban/layout.tsx new file mode 100644 index 0000000000..447a6dcb4c --- /dev/null +++ b/apps/web/core/components/base-layouts/kanban/layout.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { observer } from "mobx-react"; +import type { IBaseLayoutsKanbanItem, IBaseLayoutsKanbanProps } from "@plane/types"; +import { cn } from "@plane/utils"; +import { useLayoutState } from "../hooks/use-layout-state"; +import { BaseKanbanGroup } from "./group"; + +export const BaseKanbanLayout = observer((props: IBaseLayoutsKanbanProps) => { + const { + items, + groups, + groupedItemIds, + renderItem, + renderGroupHeader, + onDrop, + canDrag, + className, + groupClassName, + showEmptyGroups = true, + enableDragDrop = false, + loadMoreItems, + collapsedGroups: externalCollapsedGroups = [], + onToggleGroup: externalOnToggleGroup = () => {}, + } = props; + + const { containerRef, collapsedGroups, onToggleGroup } = useLayoutState({ + mode: "external", + externalCollapsedGroups, + externalOnToggleGroup, + }); + + return ( +
+ {groups.map((group) => { + const itemIds = groupedItemIds[group.id] || []; + const isCollapsed = collapsedGroups.includes(group.id); + + if (!showEmptyGroups && itemIds.length === 0) return null; + + return ( + + ); + })} +
+ ); +}); diff --git a/apps/web/core/components/base-layouts/layout-switcher.tsx b/apps/web/core/components/base-layouts/layout-switcher.tsx new file mode 100644 index 0000000000..5fe9c57cf7 --- /dev/null +++ b/apps/web/core/components/base-layouts/layout-switcher.tsx @@ -0,0 +1,50 @@ +"use client"; + +import React from "react"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { TBaseLayoutType } from "@plane/types"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +import { BASE_LAYOUTS } from "./constants"; + +type Props = { + layouts?: TBaseLayoutType[]; + onChange: (layout: TBaseLayoutType) => void; + selectedLayout: TBaseLayoutType | undefined; +}; + +export const LayoutSwitcher: React.FC = (props) => { + const { layouts, onChange, selectedLayout } = props; + const { isMobile } = usePlatformOS(); + + const handleOnChange = (layoutKey: TBaseLayoutType) => { + if (selectedLayout !== layoutKey) { + onChange(layoutKey); + } + }; + + return ( +
+ {BASE_LAYOUTS.filter((l) => (layouts ? layouts.includes(l.key) : true)).map((layout) => { + const Icon = layout.icon; + return ( + + + + ); + })} +
+ ); +}; diff --git a/apps/web/core/components/base-layouts/list/group-header.tsx b/apps/web/core/components/base-layouts/list/group-header.tsx new file mode 100644 index 0000000000..cbdca7fade --- /dev/null +++ b/apps/web/core/components/base-layouts/list/group-header.tsx @@ -0,0 +1,12 @@ +import type { IGroupHeaderProps } from "@plane/types"; + +export const GroupHeader = ({ group, itemCount, onToggleGroup }: IGroupHeaderProps) => ( + +); diff --git a/apps/web/core/components/base-layouts/list/group.tsx b/apps/web/core/components/base-layouts/list/group.tsx new file mode 100644 index 0000000000..15a8079aa2 --- /dev/null +++ b/apps/web/core/components/base-layouts/list/group.tsx @@ -0,0 +1,85 @@ +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +import type { IBaseLayoutsListItem, IBaseLayoutsListGroupProps } from "@plane/types"; +import { cn, Row } from "@plane/ui"; +import { useGroupDropTarget } from "../hooks/use-group-drop-target"; +import { GroupHeader } from "./group-header"; +import { BaseListItem } from "./item"; + +export const BaseListGroup = observer((props: IBaseLayoutsListGroupProps) => { + const { + group, + itemIds, + items, + isCollapsed, + onToggleGroup, + renderItem, + renderGroupHeader, + enableDragDrop = false, + onDrop, + canDrag, + loadMoreItems: _loadMoreItems, + } = props; + + const { t } = useTranslation(); + const { groupRef, isDraggingOver } = useGroupDropTarget({ + groupId: group.id, + enableDragDrop, + onDrop, + }); + + return ( +
+ {/* Group Header */} + + {renderGroupHeader ? ( + renderGroupHeader({ group, itemCount: itemIds.length, isCollapsed, onToggleGroup }) + ) : ( + + )} + + + {/* Group Items */} + {!isCollapsed && ( +
+ {itemIds.map((itemId: string, index: number) => { + const item = items[itemId]; + if (!item) return null; + + return ( + + ); + })} +
+ )} + + {isDraggingOver && enableDragDrop && ( +
+
+ {t("common.drop_here_to_move")} +
+
+ )} +
+ ); +}); diff --git a/apps/web/core/components/base-layouts/list/item.tsx b/apps/web/core/components/base-layouts/list/item.tsx new file mode 100644 index 0000000000..cfaa745025 --- /dev/null +++ b/apps/web/core/components/base-layouts/list/item.tsx @@ -0,0 +1,38 @@ +import { useEffect, useRef } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { observer } from "mobx-react"; +import type { IBaseLayoutsListItem, IBaseLayoutsListItemProps } from "@plane/types"; + +export const BaseListItem = observer((props: IBaseLayoutsListItemProps) => { + const { item, groupId, renderItem, enableDragDrop, canDrag, isLast: _isLast, index: _index } = props; + const itemRef = useRef(null); + + const isDragAllowed = canDrag ? canDrag(item) : true; + + useEffect(() => { + const element = itemRef.current; + if (!element || !enableDragDrop) return; + + return combine( + draggable({ + element, + canDrag: () => isDragAllowed, + getInitialData: () => ({ id: item.id, type: "ITEM", groupId }), + }), + dropTargetForElements({ + element, + getData: () => ({ groupId, type: "ITEM" }), + canDrop: ({ source }) => source?.data?.id !== item.id, + }) + ); + }, [enableDragDrop, isDragAllowed, item.id, groupId]); + + const renderedItem = renderItem(item, groupId); + + return ( +
+ {renderedItem} +
+ ); +}); diff --git a/apps/web/core/components/base-layouts/list/layout.tsx b/apps/web/core/components/base-layouts/list/layout.tsx new file mode 100644 index 0000000000..e423e16d5a --- /dev/null +++ b/apps/web/core/components/base-layouts/list/layout.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { observer } from "mobx-react"; +import type { IBaseLayoutsListItem, IBaseLayoutsListProps } from "@plane/types"; +import { cn } from "@plane/ui"; +import { useLayoutState } from "../hooks/use-layout-state"; +import { BaseListGroup } from "./group"; + +export const BaseListLayout = observer((props: IBaseLayoutsListProps) => { + const { + items, + groupedItemIds, + groups, + renderItem, + renderGroupHeader, + enableDragDrop = false, + onDrop, + canDrag, + showEmptyGroups = false, + collapsedGroups: externalCollapsedGroups = [], + onToggleGroup: externalOnToggleGroup = () => {}, + loadMoreItems, + className, + } = props; + + const { containerRef, collapsedGroups, onToggleGroup } = useLayoutState({ + mode: "external", + externalCollapsedGroups, + externalOnToggleGroup, + }); + + return ( +
+
+ {groups.map((group) => { + const itemIds = groupedItemIds[group.id] || []; + const isCollapsed = collapsedGroups.includes(group.id); + + if (!showEmptyGroups && itemIds.length === 0) return null; + + return ( + + ); + })} +
+
+ ); +}); diff --git a/apps/web/core/components/base-layouts/loaders/layout-loader.tsx b/apps/web/core/components/base-layouts/loaders/layout-loader.tsx new file mode 100644 index 0000000000..d81d6718d3 --- /dev/null +++ b/apps/web/core/components/base-layouts/loaders/layout-loader.tsx @@ -0,0 +1,24 @@ +import type { TBaseLayoutType } from "@plane/types"; +import { KanbanLayoutLoader } from "@/components/ui/loader/layouts/kanban-layout-loader"; +import { ListLayoutLoader } from "@/components/ui/loader/layouts/list-layout-loader"; + +interface GenericLayoutLoaderProps { + layout: TBaseLayoutType; + /** Optional custom loaders to override defaults */ + customLoaders?: Partial>; +} + +export const GenericLayoutLoader = ({ layout, customLoaders }: GenericLayoutLoaderProps) => { + const CustomLoader = customLoaders?.[layout]; + if (CustomLoader) return ; + + switch (layout) { + case "list": + return ; + case "kanban": + return ; + default: + console.warn(`Unknown layout: ${layout}`); + return null; + } +}; diff --git a/apps/web/core/components/chart/utils.ts b/apps/web/core/components/chart/utils.ts index 6d6895e067..7bd8c41208 100644 --- a/apps/web/core/components/chart/utils.ts +++ b/apps/web/core/components/chart/utils.ts @@ -1,6 +1,6 @@ import { getWeekOfMonth, isValid } from "date-fns"; import { CHART_X_AXIS_DATE_PROPERTIES, ChartXAxisDateGrouping, TO_CAPITALIZE_PROPERTIES } from "@plane/constants"; -import { ChartXAxisProperty, TChart, TChartDatum } from "@plane/types"; +import type { ChartXAxisProperty, TChart, TChartDatum } from "@plane/types"; import { capitalizeFirstLetter, hexToHsl, diff --git a/apps/web/core/components/command-palette/actions/help-actions.tsx b/apps/web/core/components/command-palette/actions/help-actions.tsx deleted file mode 100644 index ba7c5b3536..0000000000 --- a/apps/web/core/components/command-palette/actions/help-actions.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { GithubIcon, MessageSquare, Rocket } from "lucide-react"; -// ui -import { DiscordIcon, PageIcon } from "@plane/propel/icons"; -// hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -import { useTransient } from "@/hooks/store/use-transient"; - -type Props = { - closePalette: () => void; -}; - -export const CommandPaletteHelpActions: React.FC = observer((props) => { - const { closePalette } = props; - // hooks - const { toggleShortcutModal } = useCommandPalette(); - const { toggleIntercom } = useTransient(); - - return ( - - { - closePalette(); - toggleShortcutModal(true); - }} - className="focus:outline-none" - > -
- - Open keyboard shortcuts -
-
- { - closePalette(); - window.open("https://docs.plane.so/", "_blank"); - }} - className="focus:outline-none" - > -
- - Open Plane documentation -
-
- { - closePalette(); - window.open("https://discord.com/invite/A92xrEGCge", "_blank"); - }} - className="focus:outline-none" - > -
- - Join our Discord -
-
- { - closePalette(); - window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank"); - }} - className="focus:outline-none" - > -
- - Report a bug -
-
- { - closePalette(); - toggleIntercom(true); - }} - className="focus:outline-none" - > -
- - Chat with us -
-
-
- ); -}); diff --git a/apps/web/core/components/command-palette/actions/index.ts b/apps/web/core/components/command-palette/actions/index.ts deleted file mode 100644 index 7c3af470e4..0000000000 --- a/apps/web/core/components/command-palette/actions/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./issue-actions"; -export * from "./help-actions"; -export * from "./project-actions"; -export * from "./search-results"; -export * from "./theme-actions"; -export * from "./workspace-settings-actions"; diff --git a/apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx b/apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx deleted file mode 100644 index 5fa053fa77..0000000000 --- a/apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx +++ /dev/null @@ -1,163 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react"; -import { DoubleCircleIcon } from "@plane/propel/icons"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { EIssueServiceType, TIssue } from "@plane/types"; -// hooks -// helpers -import { copyTextToClipboard } from "@plane/utils"; -// hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { useUser } from "@/hooks/store/user"; - -type Props = { - closePalette: () => void; - issueDetails: TIssue | undefined; - pages: string[]; - setPages: (pages: string[]) => void; - setPlaceholder: (placeholder: string) => void; - setSearchTerm: (searchTerm: string) => void; -}; - -export const CommandPaletteIssueActions: React.FC = observer((props) => { - const { closePalette, issueDetails, pages, setPages, setPlaceholder, setSearchTerm } = props; - // router - const { workspaceSlug } = useParams(); - // hooks - const { updateIssue } = useIssueDetail(issueDetails?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); - const { toggleCommandPaletteModal, toggleDeleteIssueModal } = useCommandPalette(); - const { data: currentUser } = useUser(); - // derived values - const issueId = issueDetails?.id; - const projectId = issueDetails?.project_id; - - const handleUpdateIssue = async (formData: Partial) => { - if (!workspaceSlug || !projectId || !issueDetails) return; - - const payload = { ...formData }; - await updateIssue(workspaceSlug.toString(), projectId.toString(), issueDetails.id, payload).catch((e) => { - console.error(e); - }); - }; - - const handleIssueAssignees = (assignee: string) => { - if (!issueDetails || !assignee) return; - - closePalette(); - const updatedAssignees = issueDetails.assignee_ids ?? []; - - if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1); - else updatedAssignees.push(assignee); - - handleUpdateIssue({ assignee_ids: updatedAssignees }); - }; - - const deleteIssue = () => { - toggleCommandPaletteModal(false); - toggleDeleteIssueModal(true); - }; - - const copyIssueUrlToClipboard = () => { - if (!issueId) return; - - const url = new URL(window.location.href); - copyTextToClipboard(url.href) - .then(() => { - setToast({ type: TOAST_TYPE.SUCCESS, title: "Copied to clipboard" }); - }) - .catch(() => { - setToast({ type: TOAST_TYPE.ERROR, title: "Some error occurred" }); - }); - }; - - const actionHeading = issueDetails?.is_epic ? "Epic actions" : "Work item actions"; - const entityType = issueDetails?.is_epic ? "epic" : "work item"; - - return ( - - { - setPlaceholder("Change state..."); - setSearchTerm(""); - setPages([...pages, "change-issue-state"]); - }} - className="focus:outline-none" - > -
- - Change state... -
-
- { - setPlaceholder("Change priority..."); - setSearchTerm(""); - setPages([...pages, "change-issue-priority"]); - }} - className="focus:outline-none" - > -
- - Change priority... -
-
- { - setPlaceholder("Assign to..."); - setSearchTerm(""); - setPages([...pages, "change-issue-assignee"]); - }} - className="focus:outline-none" - > -
- - Assign to... -
-
- { - handleIssueAssignees(currentUser?.id ?? ""); - setSearchTerm(""); - }} - className="focus:outline-none" - > -
- {issueDetails?.assignee_ids.includes(currentUser?.id ?? "") ? ( - <> - - Un-assign from me - - ) : ( - <> - - Assign to me - - )} -
-
- -
- - {`Delete ${entityType}`} -
-
- { - closePalette(); - copyIssueUrlToClipboard(); - }} - className="focus:outline-none" - > -
- - {`Copy ${entityType} URL`} -
-
-
- ); -}); diff --git a/apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx b/apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx deleted file mode 100644 index 13b5fc284b..0000000000 --- a/apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { Check } from "lucide-react"; -// plane types -import { EIssueServiceType, TIssue } from "@plane/types"; -// plane ui -import { Avatar } from "@plane/ui"; -// helpers -import { getFileURL } from "@plane/utils"; -// hooks -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { useMember } from "@/hooks/store/use-member"; - -type Props = { closePalette: () => void; issue: TIssue }; - -export const ChangeIssueAssignee: React.FC = observer((props) => { - const { closePalette, issue } = props; - // router params - const { workspaceSlug } = useParams(); - // store - const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); - const { - project: { getProjectMemberIds, getProjectMemberDetails }, - } = useMember(); - // derived values - const projectId = issue?.project_id ?? ""; - const projectMemberIds = getProjectMemberIds(projectId, false); - - const options = - projectMemberIds - ?.map((userId) => { - if (!projectId) return; - const memberDetails = getProjectMemberDetails(userId, projectId.toString()); - - return { - value: `${memberDetails?.member?.id}`, - query: `${memberDetails?.member?.display_name}`, - content: ( - <> -
- - {memberDetails?.member?.display_name} -
- {issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && ( -
- -
- )} - - ), - }; - }) - .filter((o) => o !== undefined) ?? []; - - const handleUpdateIssue = async (formData: Partial) => { - if (!workspaceSlug || !projectId || !issue) return; - - const payload = { ...formData }; - await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => { - console.error(e); - }); - }; - - const handleIssueAssignees = (assignee: string) => { - const updatedAssignees = issue.assignee_ids ?? []; - - if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1); - else updatedAssignees.push(assignee); - - handleUpdateIssue({ assignee_ids: updatedAssignees }); - closePalette(); - }; - - return ( - <> - {options.map( - (option) => - option && ( - handleIssueAssignees(option.value)} - className="focus:outline-none" - > - {option.content} - - ) - )} - - ); -}); diff --git a/apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx b/apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx deleted file mode 100644 index ef89a07067..0000000000 --- a/apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { Check } from "lucide-react"; -// plane constants -import { ISSUE_PRIORITIES } from "@plane/constants"; -// plane types -import { PriorityIcon } from "@plane/propel/icons"; -import { EIssueServiceType, TIssue, TIssuePriorities } from "@plane/types"; -// mobx store -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -// ui -// types -// constants - -type Props = { closePalette: () => void; issue: TIssue }; - -export const ChangeIssuePriority: React.FC = observer((props) => { - const { closePalette, issue } = props; - // router params - const { workspaceSlug } = useParams(); - // store hooks - const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); - // derived values - const projectId = issue?.project_id; - - const submitChanges = async (formData: Partial) => { - if (!workspaceSlug || !projectId || !issue) return; - - const payload = { ...formData }; - await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => { - console.error(e); - }); - }; - - const handleIssueState = (priority: TIssuePriorities) => { - submitChanges({ priority }); - closePalette(); - }; - - return ( - <> - {ISSUE_PRIORITIES.map((priority) => ( - handleIssueState(priority.key)} className="focus:outline-none"> -
- - {priority.title ?? "None"} -
-
{priority.key === issue.priority && }
-
- ))} - - ); -}); diff --git a/apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx b/apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx deleted file mode 100644 index 64d9e4449e..0000000000 --- a/apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane imports -import { EIssueServiceType, TIssue } from "@plane/types"; -// store hooks -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -// plane web imports -import { ChangeWorkItemStateList } from "@/plane-web/components/command-palette/actions/work-item-actions"; - -type Props = { closePalette: () => void; issue: TIssue }; - -export const ChangeIssueState: React.FC = observer((props) => { - const { closePalette, issue } = props; - // router params - const { workspaceSlug } = useParams(); - // store hooks - const { updateIssue } = useIssueDetail(issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES); - // derived values - const projectId = issue?.project_id; - const currentStateId = issue?.state_id; - - const submitChanges = async (formData: Partial) => { - if (!workspaceSlug || !projectId || !issue) return; - - const payload = { ...formData }; - await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => { - console.error(e); - }); - }; - - const handleIssueState = (stateId: string) => { - submitChanges({ state_id: stateId }); - closePalette(); - }; - - return ( - - ); -}); diff --git a/apps/web/core/components/command-palette/actions/issue-actions/index.ts b/apps/web/core/components/command-palette/actions/issue-actions/index.ts deleted file mode 100644 index 305107d606..0000000000 --- a/apps/web/core/components/command-palette/actions/issue-actions/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./actions-list"; -export * from "./change-state"; -export * from "./change-priority"; -export * from "./change-assignee"; diff --git a/apps/web/core/components/command-palette/actions/project-actions.tsx b/apps/web/core/components/command-palette/actions/project-actions.tsx deleted file mode 100644 index 9d90471469..0000000000 --- a/apps/web/core/components/command-palette/actions/project-actions.tsx +++ /dev/null @@ -1,94 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -// hooks -import { - CYCLE_TRACKER_ELEMENTS, - MODULE_TRACKER_ELEMENTS, - PROJECT_PAGE_TRACKER_ELEMENTS, - PROJECT_VIEW_TRACKER_ELEMENTS, -} from "@plane/constants"; -import { CycleIcon, ModuleIcon, PageIcon, ViewsIcon } from "@plane/propel/icons"; -// hooks -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -// ui - -type Props = { - closePalette: () => void; -}; - -export const CommandPaletteProjectActions: React.FC = (props) => { - const { closePalette } = props; - // store hooks - const { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal } = - useCommandPalette(); - - return ( - <> - - { - closePalette(); - toggleCreateCycleModal(true); - }} - className="focus:outline-none" - > -
- - Create new cycle -
- Q -
-
- - { - closePalette(); - toggleCreateModuleModal(true); - }} - className="focus:outline-none" - > -
- - Create new module -
- M -
-
- - { - closePalette(); - toggleCreateViewModal(true); - }} - className="focus:outline-none" - > -
- - Create new view -
- V -
-
- - { - closePalette(); - toggleCreatePageModal({ isOpen: true }); - }} - className="focus:outline-none" - > -
- - Create new page -
- D -
-
- - ); -}; diff --git a/apps/web/core/components/command-palette/actions/search-results.tsx b/apps/web/core/components/command-palette/actions/search-results.tsx deleted file mode 100644 index a33d85ff77..0000000000 --- a/apps/web/core/components/command-palette/actions/search-results.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane imports -import { IWorkspaceSearchResults } from "@plane/types"; -// hooks -import { useAppRouter } from "@/hooks/use-app-router"; -// plane web imports -import { commandGroups } from "@/plane-web/components/command-palette"; -// helpers -import { openProjectAndScrollToSidebar } from "./helper"; - -type Props = { - closePalette: () => void; - results: IWorkspaceSearchResults; -}; - -export const CommandPaletteSearchResults: React.FC = observer((props) => { - const { closePalette, results } = props; - // router - const router = useAppRouter(); - const { projectId: routerProjectId } = useParams(); - // derived values - const projectId = routerProjectId?.toString(); - - return ( - <> - {Object.keys(results.results).map((key) => { - // TODO: add type for results - const section = (results.results as any)[key]; - const currentSection = commandGroups[key]; - if (!currentSection) return null; - if (section.length > 0) { - return ( - - {section.map((item: any) => ( - { - closePalette(); - router.push(currentSection.path(item, projectId)); - const itemProjectId = - item?.project_id || - (Array.isArray(item?.project_ids) && item?.project_ids?.length > 0 - ? item?.project_ids[0] - : undefined); - if (itemProjectId) openProjectAndScrollToSidebar(itemProjectId); - }} - value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`} - className="focus:outline-none" - > -
- {currentSection.icon} -

{currentSection.itemName(item)}

-
-
- ))} -
- ); - } - })} - - ); -}); diff --git a/apps/web/core/components/command-palette/actions/theme-actions.tsx b/apps/web/core/components/command-palette/actions/theme-actions.tsx deleted file mode 100644 index 697ffda170..0000000000 --- a/apps/web/core/components/command-palette/actions/theme-actions.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import React, { FC, useEffect, useState } from "react"; -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useTheme } from "next-themes"; -import { Settings } from "lucide-react"; -// plane imports -import { THEME_OPTIONS } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -// hooks -import { useUserProfile } from "@/hooks/store/user"; - -type Props = { - closePalette: () => void; -}; - -export const CommandPaletteThemeActions: FC = observer((props) => { - const { closePalette } = props; - const { setTheme } = useTheme(); - // hooks - const { updateUserTheme } = useUserProfile(); - const { t } = useTranslation(); - // states - const [mounted, setMounted] = useState(false); - - const updateTheme = async (newTheme: string) => { - setTheme(newTheme); - return updateUserTheme({ theme: newTheme }).catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Failed to save user theme settings!", - }); - }); - }; - - // useEffect only runs on the client, so now we can safely show the UI - useEffect(() => { - setMounted(true); - }, []); - - if (!mounted) return null; - - return ( - <> - {THEME_OPTIONS.map((theme) => ( - { - updateTheme(theme.value); - closePalette(); - }} - className="focus:outline-none" - > -
- - {t(theme.i18n_label)} -
-
- ))} - - ); -}); diff --git a/apps/web/core/components/command-palette/actions/workspace-settings-actions.tsx b/apps/web/core/components/command-palette/actions/workspace-settings-actions.tsx deleted file mode 100644 index 87b9213374..0000000000 --- a/apps/web/core/components/command-palette/actions/workspace-settings-actions.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -// hooks -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -// components -import { SettingIcon } from "@/components/icons"; -// hooks -import { useUserPermissions } from "@/hooks/store/user"; -import { useAppRouter } from "@/hooks/use-app-router"; -// plane wev constants -// plane web helpers -import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; - -type Props = { - closePalette: () => void; -}; - -export const CommandPaletteWorkspaceSettingsActions: React.FC = (props) => { - const { closePalette } = props; - // router - const router = useAppRouter(); - // router params - const { workspaceSlug } = useParams(); - // mobx store - const { t } = useTranslation(); - const { allowPermissions } = useUserPermissions(); - // derived values - - const redirect = (path: string) => { - closePalette(); - router.push(path); - }; - - return ( - <> - {WORKSPACE_SETTINGS_LINKS.map( - (setting) => - allowPermissions(setting.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && - shouldRenderSettingLink(workspaceSlug.toString(), setting.key) && ( - redirect(`/${workspaceSlug}${setting.href}`)} - className="focus:outline-none" - > - -
- - {t(setting.i18n_label)} -
- -
- ) - )} - - ); -}; diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx deleted file mode 100644 index da375c337f..0000000000 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ /dev/null @@ -1,492 +0,0 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import { Command } from "cmdk"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import useSWR from "swr"; -import { CommandIcon, FolderPlus, Search, Settings, X } from "lucide-react"; -import { Dialog, Transition } from "@headlessui/react"; -// plane imports -import { - EUserPermissions, - EUserPermissionsLevel, - PROJECT_TRACKER_ELEMENTS, - WORK_ITEM_TRACKER_ELEMENTS, - WORKSPACE_DEFAULT_SEARCH_RESULT, -} from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { WorkItemsIcon } from "@plane/propel/icons"; -import { IWorkspaceSearchResults } from "@plane/types"; -import { Loader, ToggleSwitch } from "@plane/ui"; -import { cn, getTabIndex } from "@plane/utils"; -// components -import { - ChangeIssueAssignee, - ChangeIssuePriority, - ChangeIssueState, - CommandPaletteHelpActions, - CommandPaletteIssueActions, - CommandPaletteProjectActions, - CommandPaletteSearchResults, - CommandPaletteThemeActions, - CommandPaletteWorkspaceSettingsActions, -} from "@/components/command-palette"; -import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; -// helpers -// hooks -import { captureClick } from "@/helpers/event-tracker.helper"; -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { useProject } from "@/hooks/store/use-project"; -import { useUser, useUserPermissions } from "@/hooks/store/user"; -import { useAppRouter } from "@/hooks/use-app-router"; -import useDebounce from "@/hooks/use-debounce"; -import { usePlatformOS } from "@/hooks/use-platform-os"; -// plane web components -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; -import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; -// plane web services -import { WorkspaceService } from "@/plane-web/services"; - -const workspaceService = new WorkspaceService(); - -export const CommandModal: React.FC = observer(() => { - // router - const router = useAppRouter(); - const { workspaceSlug, projectId: routerProjectId, workItem } = useParams(); - // states - const [placeholder, setPlaceholder] = useState("Type a command or search..."); - const [resultsCount, setResultsCount] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [isSearching, setIsSearching] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [results, setResults] = useState(WORKSPACE_DEFAULT_SEARCH_RESULT); - const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); - const [pages, setPages] = useState([]); - const [searchInIssue, setSearchInIssue] = useState(false); - // plane hooks - const { t } = useTranslation(); - // hooks - const { - issue: { getIssueById }, - fetchIssueWithIdentifier, - } = useIssueDetail(); - const { workspaceProjectIds } = useProject(); - const { platform, isMobile } = usePlatformOS(); - const { canPerformAnyCreateAction } = useUser(); - const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } = - useCommandPalette(); - const { allowPermissions } = useUserPermissions(); - const projectIdentifier = workItem?.toString().split("-")[0]; - const sequence_id = workItem?.toString().split("-")[1]; - // fetch work item details using identifier - const { data: workItemDetailsSWR } = useSWR( - workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null, - workspaceSlug && workItem - ? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) - : null - ); - - // derived values - const issueDetails = workItemDetailsSWR ? getIssueById(workItemDetailsSWR?.id) : null; - const issueId = issueDetails?.id; - const projectId = issueDetails?.project_id ?? routerProjectId; - const page = pages[pages.length - 1]; - const debouncedSearchTerm = useDebounce(searchTerm, 500); - const { baseTabIndex } = getTabIndex(undefined, isMobile); - const canPerformWorkspaceActions = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.WORKSPACE - ); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); - - useEffect(() => { - if (issueDetails && isCommandPaletteOpen) { - setSearchInIssue(true); - } - }, [issueDetails, isCommandPaletteOpen]); - - useEffect(() => { - if (!projectId && !isWorkspaceLevel) { - setIsWorkspaceLevel(true); - } else { - setIsWorkspaceLevel(false); - } - }, [projectId]); - - const closePalette = () => { - toggleCommandPaletteModal(false); - }; - - const createNewWorkspace = () => { - closePalette(); - router.push("/create-workspace"); - }; - - useEffect( - () => { - if (!workspaceSlug) return; - - setIsLoading(true); - - if (debouncedSearchTerm) { - setIsSearching(true); - workspaceService - .searchWorkspace(workspaceSlug.toString(), { - ...(projectId ? { project_id: projectId.toString() } : {}), - search: debouncedSearchTerm, - workspace_search: !projectId ? true : isWorkspaceLevel, - }) - .then((results) => { - setResults(results); - const count = Object.keys(results.results).reduce( - (accumulator, key) => (results.results as any)[key].length + accumulator, - 0 - ); - setResultsCount(count); - }) - .finally(() => { - setIsLoading(false); - setIsSearching(false); - }); - } else { - setResults(WORKSPACE_DEFAULT_SEARCH_RESULT); - setIsLoading(false); - setIsSearching(false); - } - }, - [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes - ); - - return ( - setSearchTerm("")} as={React.Fragment}> - { - closePalette(); - if (searchInIssue) { - setSearchInIssue(true); - } - }} - > - -
- - -
-
- - -
- { - if (value.toLowerCase().includes(search.toLowerCase())) return 1; - return 0; - }} - shouldFilter={searchTerm.length > 0} - onKeyDown={(e: any) => { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { - e.preventDefault(); - e.stopPropagation(); - closePalette(); - return; - } - - if (e.key === "Tab") { - e.preventDefault(); - const commandList = document.querySelector("[cmdk-list]"); - const items = commandList?.querySelectorAll("[cmdk-item]") || []; - const selectedItem = commandList?.querySelector('[aria-selected="true"]'); - if (items.length === 0) return; - - const currentIndex = Array.from(items).indexOf(selectedItem as Element); - let nextIndex; - - if (e.shiftKey) { - nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1; - } else { - nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0; - } - - const nextItem = items[nextIndex] as HTMLElement; - if (nextItem) { - nextItem.setAttribute("aria-selected", "true"); - selectedItem?.setAttribute("aria-selected", "false"); - nextItem.focus(); - nextItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); - } - } - - if (e.key === "Escape" && searchTerm) { - e.preventDefault(); - setSearchTerm(""); - } - - if (e.key === "Escape" && !page && !searchTerm) { - e.preventDefault(); - closePalette(); - } - - if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) { - e.preventDefault(); - setPages((pages) => pages.slice(0, -1)); - setPlaceholder("Type a command or search..."); - } - }} - > -
-
-
- setSearchTerm(e)} - autoFocus - tabIndex={baseTabIndex} - /> -
- - - {searchTerm !== "" && ( -
- Search results for{" "} - - {'"'} - {searchTerm} - {'"'} - {" "} - in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: -
- )} - - {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( -
- -
- )} - - {(isLoading || isSearching) && ( - - - - - - - - - )} - - {debouncedSearchTerm !== "" && ( - - )} - - {!page && ( - <> - {/* issue actions */} - {issueId && issueDetails && searchInIssue && ( - setPages(newPages)} - setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)} - setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} - /> - )} - {workspaceSlug && - workspaceProjectIds && - workspaceProjectIds.length > 0 && - canPerformAnyCreateAction && ( - - { - closePalette(); - captureClick({ - elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON, - }); - toggleCreateIssueModal(true); - }} - className="focus:bg-custom-background-80" - > -
- - Create new work item -
- C -
-
- )} - {workspaceSlug && canPerformWorkspaceActions && ( - - { - closePalette(); - captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON }); - toggleCreateProjectModal(true); - }} - className="focus:outline-none" - > -
- - Create new project -
- P -
-
- )} - - {/* project actions */} - {projectId && canPerformAnyCreateAction && ( - - )} - {canPerformWorkspaceActions && ( - - { - setPlaceholder("Search workspace settings..."); - setSearchTerm(""); - setPages([...pages, "settings"]); - }} - className="focus:outline-none" - > -
- - Search settings... -
-
-
- )} - - -
- - Create new workspace -
-
- { - setPlaceholder("Change interface theme..."); - setSearchTerm(""); - setPages([...pages, "change-interface-theme"]); - }} - className="focus:outline-none" - > -
- - Change interface theme... -
-
-
- - {/* help options */} - - - )} - - {/* workspace settings actions */} - {page === "settings" && workspaceSlug && ( - - )} - - {/* issue details page actions */} - {page === "change-issue-state" && issueDetails && ( - - )} - {page === "change-issue-priority" && issueDetails && ( - - )} - {page === "change-issue-assignee" && issueDetails && ( - - )} - - {/* theme actions */} - {page === "change-interface-theme" && ( - { - closePalette(); - setPages((pages) => pages.slice(0, -1)); - }} - /> - )} -
-
-
- {/* Bottom overlay */} -
-
- Actions -
-
- {platform === "MacOS" ? : "Ctrl"} -
- - K - -
-
-
- Workspace Level - setIsWorkspaceLevel((prevData) => !prevData)} - disabled={!projectId} - size="sm" - /> -
-
-
-
-
-
-
-
- ); -}); diff --git a/apps/web/core/components/command-palette/command-palette.tsx b/apps/web/core/components/command-palette/command-palette.tsx deleted file mode 100644 index a458f01c2b..0000000000 --- a/apps/web/core/components/command-palette/command-palette.tsx +++ /dev/null @@ -1,269 +0,0 @@ -"use client"; - -import React, { useCallback, useEffect, FC, useMemo } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import useSWR from "swr"; -// ui -import { COMMAND_PALETTE_TRACKER_ELEMENTS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -// components -import { copyTextToClipboard } from "@plane/utils"; -import { CommandModal, ShortcutsModal } from "@/components/command-palette"; -// helpers -// hooks -import { captureClick } from "@/helpers/event-tracker.helper"; -import { useAppTheme } from "@/hooks/store/use-app-theme"; -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { useUser, useUserPermissions } from "@/hooks/store/user"; -import { usePlatformOS } from "@/hooks/use-platform-os"; -// plane web components -import { - IssueLevelModals, - ProjectLevelModals, - WorkspaceLevelModals, -} from "@/plane-web/components/command-palette/modals"; -// plane web constants -// plane web helpers -import { - getGlobalShortcutsList, - getProjectShortcutsList, - getWorkspaceShortcutsList, - handleAdditionalKeyDownEvents, -} from "@/plane-web/helpers/command-palette"; - -export const CommandPalette: FC = observer(() => { - // router params - const { workspaceSlug, projectId: paramsProjectId, workItem } = useParams(); - // store hooks - const { fetchIssueWithIdentifier } = useIssueDetail(); - const { toggleSidebar, toggleExtendedSidebar } = useAppTheme(); - const { platform } = usePlatformOS(); - const { data: currentUser, canPerformAnyCreateAction } = useUser(); - const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen } = useCommandPalette(); - const { allowPermissions } = useUserPermissions(); - - // derived values - const projectIdentifier = workItem?.toString().split("-")[0]; - const sequence_id = workItem?.toString().split("-")[1]; - - const { data: issueDetails } = useSWR( - workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null, - workspaceSlug && workItem - ? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) - : null - ); - - const issueId = issueDetails?.id; - const projectId = paramsProjectId?.toString() ?? issueDetails?.project_id; - - const canPerformWorkspaceMemberActions = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.WORKSPACE - ); - const canPerformProjectMemberActions = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT, - workspaceSlug?.toString(), - projectId - ); - const canPerformProjectAdminActions = allowPermissions( - [EUserPermissions.ADMIN], - EUserPermissionsLevel.PROJECT, - workspaceSlug?.toString(), - projectId - ); - - const copyIssueUrlToClipboard = useCallback(() => { - if (!workItem) return; - - const url = new URL(window.location.href); - copyTextToClipboard(url.href) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", - }); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); - }); - }, [workItem]); - - // auth - const performProjectCreateActions = useCallback( - (showToast: boolean = true) => { - if (!canPerformProjectMemberActions && showToast) - setToast({ - type: TOAST_TYPE.ERROR, - title: "You don't have permission to perform this action.", - }); - - return canPerformProjectMemberActions; - }, - [canPerformProjectMemberActions] - ); - - const performProjectBulkDeleteActions = useCallback( - (showToast: boolean = true) => { - if (!canPerformProjectAdminActions && projectId && showToast) - setToast({ - type: TOAST_TYPE.ERROR, - title: "You don't have permission to perform this action.", - }); - - return canPerformProjectAdminActions; - }, - [canPerformProjectAdminActions, projectId] - ); - - const performWorkspaceCreateActions = useCallback( - (showToast: boolean = true) => { - if (!canPerformWorkspaceMemberActions && showToast) - setToast({ - type: TOAST_TYPE.ERROR, - title: "You don't have permission to perform this action.", - }); - return canPerformWorkspaceMemberActions; - }, - [canPerformWorkspaceMemberActions] - ); - - const performAnyProjectCreateActions = useCallback( - (showToast: boolean = true) => { - if (!canPerformAnyCreateAction && showToast) - setToast({ - type: TOAST_TYPE.ERROR, - title: "You don't have permission to perform this action.", - }); - return canPerformAnyCreateAction; - }, - [canPerformAnyCreateAction] - ); - - const shortcutsList: { - global: Record void }>; - workspace: Record void }>; - project: Record void }>; - } = useMemo( - () => ({ - global: getGlobalShortcutsList(), - workspace: getWorkspaceShortcutsList(), - project: getProjectShortcutsList(), - }), - [] - ); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - const { key, ctrlKey, metaKey, altKey, shiftKey } = e; - if (!key) return; - - const keyPressed = key.toLowerCase(); - const cmdClicked = ctrlKey || metaKey; - const shiftClicked = shiftKey; - const deleteKey = keyPressed === "backspace" || keyPressed === "delete"; - - if (cmdClicked && keyPressed === "k" && !isAnyModalOpen) { - e.preventDefault(); - toggleCommandPaletteModal(true); - } - - // if on input, textarea or editor, don't do anything - if ( - e.target instanceof HTMLTextAreaElement || - e.target instanceof HTMLInputElement || - (e.target as Element)?.classList?.contains("ProseMirror") - ) - return; - - if (shiftClicked && (keyPressed === "?" || keyPressed === "/") && !isAnyModalOpen) { - e.preventDefault(); - toggleShortcutModal(true); - } - - if (deleteKey) { - if (performProjectBulkDeleteActions()) { - shortcutsList.project.delete.action(); - } - } else if (cmdClicked) { - if (keyPressed === "c" && ((platform === "MacOS" && ctrlKey) || altKey)) { - e.preventDefault(); - copyIssueUrlToClipboard(); - } else if (keyPressed === "b") { - e.preventDefault(); - toggleSidebar(); - toggleExtendedSidebar(false); - } - } else if (!isAnyModalOpen) { - captureClick({ elementName: COMMAND_PALETTE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_KEY }); - if ( - Object.keys(shortcutsList.global).includes(keyPressed) && - ((!projectId && performAnyProjectCreateActions()) || performProjectCreateActions()) - ) { - shortcutsList.global[keyPressed].action(); - } - // workspace authorized actions - else if ( - Object.keys(shortcutsList.workspace).includes(keyPressed) && - workspaceSlug && - performWorkspaceCreateActions() - ) { - e.preventDefault(); - shortcutsList.workspace[keyPressed].action(); - } - // project authorized actions - else if ( - Object.keys(shortcutsList.project).includes(keyPressed) && - projectId && - performProjectCreateActions() - ) { - e.preventDefault(); - // actions that can be performed only inside a project - shortcutsList.project[keyPressed].action(); - } - } - // Additional keydown events - handleAdditionalKeyDownEvents(e); - }, - [ - copyIssueUrlToClipboard, - isAnyModalOpen, - platform, - performAnyProjectCreateActions, - performProjectBulkDeleteActions, - performProjectCreateActions, - performWorkspaceCreateActions, - projectId, - shortcutsList, - toggleCommandPaletteModal, - toggleShortcutModal, - toggleSidebar, - toggleExtendedSidebar, - workspaceSlug, - ] - ); - - useEffect(() => { - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [handleKeyDown]); - - if (!currentUser) return null; - - return ( - <> - toggleShortcutModal(false)} /> - {workspaceSlug && } - {workspaceSlug && projectId && ( - - )} - - - - ); -}); diff --git a/apps/web/core/components/command-palette/index.ts b/apps/web/core/components/command-palette/index.ts deleted file mode 100644 index 5aee700af3..0000000000 --- a/apps/web/core/components/command-palette/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./actions"; -export * from "./shortcuts-modal"; -export * from "./command-modal"; -export * from "./command-palette"; diff --git a/apps/web/core/components/command-palette/shortcuts-modal/commands-list.tsx b/apps/web/core/components/command-palette/shortcuts-modal/commands-list.tsx deleted file mode 100644 index c01eff48f6..0000000000 --- a/apps/web/core/components/command-palette/shortcuts-modal/commands-list.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { Command } from "lucide-react"; -// helpers -import { substringMatch } from "@plane/utils"; -// hooks -import { usePlatformOS } from "@/hooks/use-platform-os"; -// plane web helpers -import { - getAdditionalShortcutsList, - getCommonShortcutsList, - getNavigationShortcutsList, -} from "@/plane-web/helpers/command-palette"; - -type Props = { - searchQuery: string; -}; - -export const ShortcutCommandsList: React.FC = (props) => { - const { searchQuery } = props; - const { platform } = usePlatformOS(); - - const KEYBOARD_SHORTCUTS = [ - { - key: "navigation", - title: "Navigation", - shortcuts: getNavigationShortcutsList(), - }, - { - key: "common", - title: "Common", - shortcuts: getCommonShortcutsList(platform), - }, - ...getAdditionalShortcutsList(), - ]; - - const filteredShortcuts = KEYBOARD_SHORTCUTS.map((category) => { - const newCategory = { ...category }; - - newCategory.shortcuts = newCategory.shortcuts.filter((shortcut) => - substringMatch(shortcut.description, searchQuery) - ); - - return newCategory; - }); - - const isShortcutsEmpty = filteredShortcuts.every((category) => category.shortcuts.length === 0); - - return ( -
- {!isShortcutsEmpty ? ( - filteredShortcuts.map((category) => { - if (category.shortcuts.length === 0) return; - - return ( -
-
{category.title}
-
- {category.shortcuts.map((shortcut) => ( -
-
-

{shortcut.description}

-
- {shortcut.keys.split(",").map((key) => ( -
- {key === "Ctrl" ? ( -
- {platform === "MacOS" ? ( - - ) : ( - "Ctrl" - )} -
- ) : ( - - {key} - - )} -
- ))} -
-
-
- ))} -
-
- ); - }) - ) : ( -

- No shortcuts found for{" "} - - {`"`} - {searchQuery} - {`"`} - -

- )} -
- ); -}; diff --git a/apps/web/core/components/command-palette/shortcuts-modal/index.ts b/apps/web/core/components/command-palette/shortcuts-modal/index.ts deleted file mode 100644 index 9346fb2b4c..0000000000 --- a/apps/web/core/components/command-palette/shortcuts-modal/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./commands-list"; -export * from "./modal"; diff --git a/apps/web/core/components/comments/card/display.tsx b/apps/web/core/components/comments/card/display.tsx index 68ceb09d10..16ea26378c 100644 --- a/apps/web/core/components/comments/card/display.tsx +++ b/apps/web/core/components/comments/card/display.tsx @@ -5,7 +5,8 @@ import { Globe2, Lock } from "lucide-react"; // plane imports import type { EditorRefApi } from "@plane/editor"; import { useHashScroll } from "@plane/hooks"; -import { EIssueCommentAccessSpecifier, type TCommentsOperations, type TIssueComment } from "@plane/types"; +import { EIssueCommentAccessSpecifier } from "@plane/types"; +import type { TCommentsOperations, TIssueComment } from "@plane/types"; import { cn } from "@plane/utils"; // components import { LiteTextEditor } from "@/components/editor/lite-text"; diff --git a/apps/web/core/components/comments/card/edit-form.tsx b/apps/web/core/components/comments/card/edit-form.tsx index b370db0a22..fcaa6b3525 100644 --- a/apps/web/core/components/comments/card/edit-form.tsx +++ b/apps/web/core/components/comments/card/edit-form.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useRef } from "react"; import { observer } from "mobx-react"; import { useForm } from "react-hook-form"; -import { Check, X } from "lucide-react"; -// plane imports +import { Check } from "lucide-react"; import type { EditorRefApi } from "@plane/editor"; +import { CloseIcon } from "@plane/propel/icons"; +// plane imports import type { TCommentsOperations, TIssueComment } from "@plane/types"; import { isCommentEmpty } from "@plane/utils"; // components @@ -123,7 +124,7 @@ export const CommentCardEditForm: React.FC = observer((props) => { editorRef.current?.setEditorValue(comment.comment_html ?? "

"); }} > - +
diff --git a/apps/web/core/components/comments/card/root.tsx b/apps/web/core/components/comments/card/root.tsx index 50fc7e1a57..f55d3fd179 100644 --- a/apps/web/core/components/comments/card/root.tsx +++ b/apps/web/core/components/comments/card/root.tsx @@ -1,6 +1,7 @@ "use client"; -import { FC, useRef, useState } from "react"; +import type { FC } from "react"; +import { useRef, useState } from "react"; import { observer } from "mobx-react"; // plane imports import type { EditorRefApi } from "@plane/editor"; diff --git a/apps/web/core/components/comments/comment-create.tsx b/apps/web/core/components/comments/comment-create.tsx index e2b40641bd..a9b2423f90 100644 --- a/apps/web/core/components/comments/comment-create.tsx +++ b/apps/web/core/components/comments/comment-create.tsx @@ -1,4 +1,5 @@ -import { FC, useRef, useState } from "react"; +import type { FC } from "react"; +import { useRef, useState } from "react"; import { observer } from "mobx-react"; import { useForm, Controller } from "react-hook-form"; // plane imports diff --git a/apps/web/core/components/comments/comment-reaction.tsx b/apps/web/core/components/comments/comment-reaction.tsx index 4ab763116f..8e3e04b94b 100644 --- a/apps/web/core/components/comments/comment-reaction.tsx +++ b/apps/web/core/components/comments/comment-reaction.tsx @@ -1,10 +1,10 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { Tooltip } from "@plane/propel/tooltip"; -import { TCommentsOperations, TIssueComment } from "@plane/types"; +import type { TCommentsOperations, TIssueComment } from "@plane/types"; import { cn } from "@plane/utils"; // helpers import { renderEmoji } from "@/helpers/emoji.helper"; diff --git a/apps/web/core/components/comments/comments.tsx b/apps/web/core/components/comments/comments.tsx index bc15adcc12..9f2cfc6c55 100644 --- a/apps/web/core/components/comments/comments.tsx +++ b/apps/web/core/components/comments/comments.tsx @@ -1,11 +1,12 @@ "use client"; -import React, { FC, useMemo } from "react"; +import type { FC } from "react"; +import React, { useMemo } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import { E_SORT_ORDER } from "@plane/constants"; -import { TCommentsOperations, TIssueComment } from "@plane/types"; +import type { E_SORT_ORDER } from "@plane/constants"; +import type { TCommentsOperations, TIssueComment } from "@plane/types"; // local components import { CommentCard } from "./card/root"; import { CommentCreate } from "./comment-create"; diff --git a/apps/web/core/components/comments/quick-actions.tsx b/apps/web/core/components/comments/quick-actions.tsx index 4f62c359de..32f176544c 100644 --- a/apps/web/core/components/comments/quick-actions.tsx +++ b/apps/web/core/components/comments/quick-actions.tsx @@ -1,13 +1,15 @@ "use client"; -import { FC, useMemo } from "react"; +import type { FC } from "react"; +import { useMemo } from "react"; import { observer } from "mobx-react"; import { Globe2, Link, Lock, Pencil, Trash2 } from "lucide-react"; // plane imports import { EIssueCommentAccessSpecifier } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import type { TIssueComment, TCommentsOperations } from "@plane/types"; -import { CustomMenu, TContextMenuItem } from "@plane/ui"; +import type { TContextMenuItem } from "@plane/ui"; +import { CustomMenu } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { useUser } from "@/hooks/store/user"; diff --git a/apps/web/core/components/common/access-field.tsx b/apps/web/core/components/common/access-field.tsx index 0a96d7c3c1..42006e9f72 100644 --- a/apps/web/core/components/common/access-field.tsx +++ b/apps/web/core/components/common/access-field.tsx @@ -1,4 +1,4 @@ -import { LucideIcon } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; // plane ui import { useTranslation } from "@plane/i18n"; import { Tooltip } from "@plane/propel/tooltip"; diff --git a/apps/web/core/components/common/activity/activity-block.tsx b/apps/web/core/components/common/activity/activity-block.tsx index 7fc8d1d567..caf48975f1 100644 --- a/apps/web/core/components/common/activity/activity-block.tsx +++ b/apps/web/core/components/common/activity/activity-block.tsx @@ -1,10 +1,10 @@ "use client"; -import { FC, ReactNode } from "react"; +import type { FC, ReactNode } from "react"; import { Network } from "lucide-react"; // types import { Tooltip } from "@plane/propel/tooltip"; -import { TWorkspaceBaseActivity } from "@plane/types"; +import type { TWorkspaceBaseActivity } from "@plane/types"; // ui // helpers import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@plane/utils"; diff --git a/apps/web/core/components/common/activity/activity-item.tsx b/apps/web/core/components/common/activity/activity-item.tsx index 643d897383..6df28e1d88 100644 --- a/apps/web/core/components/common/activity/activity-item.tsx +++ b/apps/web/core/components/common/activity/activity-item.tsx @@ -1,9 +1,9 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; -import { TProjectActivity } from "@/plane-web/types"; +import type { TProjectActivity } from "@/plane-web/types"; import { ActivityBlockComponent } from "./activity-block"; import { iconsMap, messages } from "./helper"; diff --git a/apps/web/core/components/common/activity/helper.tsx b/apps/web/core/components/common/activity/helper.tsx index 812bdf4f73..8c656f33e2 100644 --- a/apps/web/core/components/common/activity/helper.tsx +++ b/apps/web/core/components/common/activity/helper.tsx @@ -1,17 +1,13 @@ -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { - Signal, RotateCcw, Network, Link as LinkIcon, Calendar, - Tag, Inbox, AlignLeft, - Users, Paperclip, Type, - Triangle, FileText, Globe, Hash, @@ -25,28 +21,40 @@ import { } from "lucide-react"; // components -import { ArchiveIcon, CycleIcon, DoubleCircleIcon, IntakeIcon, ModuleIcon } from "@plane/propel/icons"; +import { + ArchiveIcon, + CycleIcon, + StatePropertyIcon, + IntakeIcon, + ModuleIcon, + PriorityPropertyIcon, + StartDatePropertyIcon, + DueDatePropertyIcon, + LabelPropertyIcon, + MembersPropertyIcon, + EstimatePropertyIcon, +} from "@plane/propel/icons"; import { store } from "@/lib/store-context"; -import { TProjectActivity } from "@/plane-web/types"; +import type { TProjectActivity } from "@/plane-web/types"; type ActivityIconMap = { [key: string]: ReactNode; }; export const iconsMap: ActivityIconMap = { - priority: , + priority: , archived_at: , restored: , link: , - start_date: , - target_date: , - label: , + start_date: , + target_date: , + label: , inbox: , description: , - assignee: , + assignee: , attachment: , name: , - state: , - estimate: , + state: , + estimate: , cycle: , module: , page: , diff --git a/apps/web/core/components/common/activity/user.tsx b/apps/web/core/components/common/activity/user.tsx index 7eba5cfd19..5da5b305f3 100644 --- a/apps/web/core/components/common/activity/user.tsx +++ b/apps/web/core/components/common/activity/user.tsx @@ -1,8 +1,8 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; // types -import { TWorkspaceBaseActivity } from "@plane/types"; +import type { TWorkspaceBaseActivity } from "@plane/types"; // store hooks import { useMember } from "@/hooks/store/use-member"; import { useWorkspace } from "@/hooks/store/use-workspace"; diff --git a/apps/web/core/components/common/applied-filters/date.tsx b/apps/web/core/components/common/applied-filters/date.tsx index 3ecf8a540f..3d8d94cf82 100644 --- a/apps/web/core/components/common/applied-filters/date.tsx +++ b/apps/web/core/components/common/applied-filters/date.tsx @@ -1,8 +1,8 @@ import { observer } from "mobx-react"; // icons -import { X } from "lucide-react"; -// plane constants import { DATE_BEFORE_FILTER_OPTIONS } from "@plane/constants"; +import { CloseIcon } from "@plane/propel/icons"; +// plane constants import { renderFormattedDate, capitalizeFirstLetter } from "@plane/utils"; // helpers type Props = { @@ -44,7 +44,7 @@ export const AppliedDateFilters: React.FC = observer((props) => { className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" onClick={() => handleRemove(date)} > - + )}
diff --git a/apps/web/core/components/common/applied-filters/members.tsx b/apps/web/core/components/common/applied-filters/members.tsx index 49b0f1a304..a1823132f5 100644 --- a/apps/web/core/components/common/applied-filters/members.tsx +++ b/apps/web/core/components/common/applied-filters/members.tsx @@ -1,7 +1,7 @@ "use client"; import { observer } from "mobx-react"; -import { X } from "lucide-react"; +import { CloseIcon } from "@plane/propel/icons"; // plane ui import { Avatar } from "@plane/ui"; // helpers @@ -44,7 +44,7 @@ export const AppliedMembersFilters: React.FC = observer((props) => { className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" onClick={() => handleRemove(memberId)} > - + )} diff --git a/apps/web/core/components/common/breadcrumb-link.tsx b/apps/web/core/components/common/breadcrumb-link.tsx index a0fda7c147..25ee4ee1c7 100644 --- a/apps/web/core/components/common/breadcrumb-link.tsx +++ b/apps/web/core/components/common/breadcrumb-link.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { ReactNode, useMemo, FC } from "react"; +import type { ReactNode, FC } from "react"; +import React, { useMemo } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { Breadcrumbs } from "@plane/ui"; diff --git a/apps/web/core/components/common/count-chip.tsx b/apps/web/core/components/common/count-chip.tsx index f44f349bff..ff15e5011d 100644 --- a/apps/web/core/components/common/count-chip.tsx +++ b/apps/web/core/components/common/count-chip.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/common/latest-feature-block.tsx b/apps/web/core/components/common/latest-feature-block.tsx index 327b03ea5b..81b74cb61a 100644 --- a/apps/web/core/components/common/latest-feature-block.tsx +++ b/apps/web/core/components/common/latest-feature-block.tsx @@ -4,7 +4,7 @@ import { useTheme } from "next-themes"; // icons import { Lightbulb } from "lucide-react"; // images -import latestFeatures from "@/public/onboarding/onboarding-pages.webp"; +import latestFeatures from "@/app/assets/onboarding/onboarding-pages.webp?url"; export const LatestFeatureBlock = () => { const { resolvedTheme } = useTheme(); diff --git a/apps/web/core/components/common/logo-spinner.tsx b/apps/web/core/components/common/logo-spinner.tsx index 0e902f307a..e92e30e0a0 100644 --- a/apps/web/core/components/common/logo-spinner.tsx +++ b/apps/web/core/components/common/logo-spinner.tsx @@ -1,8 +1,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(); diff --git a/apps/web/core/components/common/logo.tsx b/apps/web/core/components/common/logo.tsx index c03f057341..12a7ff06bf 100644 --- a/apps/web/core/components/common/logo.tsx +++ b/apps/web/core/components/common/logo.tsx @@ -1,13 +1,13 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // Due to some weird issue with the import order, the import of useFontFaceObserver // should be after the imported here rather than some below helper functions as it is in the original file // eslint-disable-next-line import/order import useFontFaceObserver from "use-font-face-observer"; // plane imports import { getEmojiSize, LUCIDE_ICONS_LIST, stringToEmoji } from "@plane/propel/emoji-icon-picker"; -import { TLogoProps } from "@plane/types"; +import type { TLogoProps } from "@plane/types"; type Props = { logo: TLogoProps; diff --git a/apps/web/core/components/common/page-access-icon.tsx b/apps/web/core/components/common/page-access-icon.tsx index 9f547a1163..bb61dc62bb 100644 --- a/apps/web/core/components/common/page-access-icon.tsx +++ b/apps/web/core/components/common/page-access-icon.tsx @@ -1,6 +1,6 @@ import { ArchiveIcon, Earth, Lock } from "lucide-react"; import { EPageAccess } from "@plane/constants"; -import { TPage } from "@plane/types"; +import type { TPage } from "@plane/types"; export const PageAccessIcon = (page: TPage) => (
diff --git a/apps/web/core/components/common/pro-icon.tsx b/apps/web/core/components/common/pro-icon.tsx index 47300b6d6c..45f3a698c5 100644 --- a/apps/web/core/components/common/pro-icon.tsx +++ b/apps/web/core/components/common/pro-icon.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { Crown } from "lucide-react"; // helpers import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/common/switcher-label.tsx b/apps/web/core/components/common/switcher-label.tsx index 14ed1bb54c..7f88c6cdd9 100644 --- a/apps/web/core/components/common/switcher-label.tsx +++ b/apps/web/core/components/common/switcher-label.tsx @@ -1,6 +1,6 @@ -import { FC } from "react"; -import { ISvgIcons } from "@plane/propel/icons"; -import { TLogoProps } from "@plane/types"; +import type { FC } from "react"; +import type { ISvgIcons } from "@plane/propel/icons"; +import type { TLogoProps } from "@plane/types"; import { getFileURL, truncateText } from "@plane/utils"; import { Logo } from "@/components/common/logo"; diff --git a/apps/web/core/components/core/activity.tsx b/apps/web/core/components/core/activity.tsx index 33d796928f..d50a565e5d 100644 --- a/apps/web/core/components/core/activity.tsx +++ b/apps/web/core/components/core/activity.tsx @@ -30,7 +30,7 @@ import { WorkItemsIcon, } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { IIssueActivity } from "@plane/types"; +import type { IIssueActivity } from "@plane/types"; import { renderFormattedDate, generateWorkItemLink, capitalizeFirstLetter } from "@plane/utils"; // helpers import { useLabel } from "@/hooks/store/use-label"; diff --git a/apps/web/core/components/core/app-header.tsx b/apps/web/core/components/core/app-header.tsx index 515165347c..f5f7efab4f 100644 --- a/apps/web/core/components/core/app-header.tsx +++ b/apps/web/core/components/core/app-header.tsx @@ -1,6 +1,6 @@ "use client"; -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { observer } from "mobx-react"; // plane imports import { Row } from "@plane/ui"; diff --git a/apps/web/core/components/core/content-overflow-HOC.tsx b/apps/web/core/components/core/content-overflow-HOC.tsx index f8089c34bc..1bbd5896aa 100644 --- a/apps/web/core/components/core/content-overflow-HOC.tsx +++ b/apps/web/core/components/core/content-overflow-HOC.tsx @@ -1,4 +1,5 @@ -import { ReactNode, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/core/content-wrapper.tsx b/apps/web/core/components/core/content-wrapper.tsx index 143ae085e9..6aa98c9db0 100644 --- a/apps/web/core/components/core/content-wrapper.tsx +++ b/apps/web/core/components/core/content-wrapper.tsx @@ -1,6 +1,6 @@ "use client"; -import { ReactNode } from "react"; +import type { ReactNode } from "react"; // helpers import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/core/description-versions/dropdown-item.tsx b/apps/web/core/components/core/description-versions/dropdown-item.tsx index 37c989f2f0..e94e36cf78 100644 --- a/apps/web/core/components/core/description-versions/dropdown-item.tsx +++ b/apps/web/core/components/core/description-versions/dropdown-item.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TDescriptionVersion } from "@plane/types"; +import type { TDescriptionVersion } from "@plane/types"; import { Avatar, CustomMenu } from "@plane/ui"; import { calculateTimeAgo, getFileURL } from "@plane/utils"; // hooks diff --git a/apps/web/core/components/core/description-versions/dropdown.tsx b/apps/web/core/components/core/description-versions/dropdown.tsx index bef4f63a3f..dc54e47bee 100644 --- a/apps/web/core/components/core/description-versions/dropdown.tsx +++ b/apps/web/core/components/core/description-versions/dropdown.tsx @@ -2,14 +2,14 @@ import { observer } from "mobx-react"; import { History } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TDescriptionVersion } from "@plane/types"; +import type { TDescriptionVersion } from "@plane/types"; import { CustomMenu } from "@plane/ui"; import { calculateTimeAgo } from "@plane/utils"; // hooks import { useMember } from "@/hooks/store/use-member"; // local imports import { DescriptionVersionsDropdownItem } from "./dropdown-item"; -import { TDescriptionVersionEntityInformation } from "./root"; +import type { TDescriptionVersionEntityInformation } from "./root"; type Props = { disabled: boolean; diff --git a/apps/web/core/components/core/description-versions/modal.tsx b/apps/web/core/components/core/description-versions/modal.tsx index e79f1c1e4a..15e2914942 100644 --- a/apps/web/core/components/core/description-versions/modal.tsx +++ b/apps/web/core/components/core/description-versions/modal.tsx @@ -1,13 +1,14 @@ import { useCallback, useRef } from "react"; import { observer } from "mobx-react"; -import { ChevronLeft, ChevronRight, Copy } from "lucide-react"; +import { Copy } from "lucide-react"; // plane imports import type { EditorRefApi } from "@plane/editor"; import { useTranslation } from "@plane/i18n"; import { Button, getButtonStyling } from "@plane/propel/button"; +import { ChevronLeftIcon, ChevronRightIcon } from "@plane/propel/icons"; import { setToast, TOAST_TYPE } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import { TDescriptionVersion } from "@plane/types"; +import type { TDescriptionVersion } from "@plane/types"; import { Avatar, EModalPosition, EModalWidth, Loader, ModalCore } from "@plane/ui"; import { calculateTimeAgo, cn, copyTextToClipboard, getFileURL } from "@plane/utils"; // components @@ -102,7 +103,7 @@ export const DescriptionVersionsModal: React.FC = observer((props) => { )} disabled={isPrevDisabled} > - +
diff --git a/apps/web/core/components/core/description-versions/root.tsx b/apps/web/core/components/core/description-versions/root.tsx index abd1ae31ed..6813508279 100644 --- a/apps/web/core/components/core/description-versions/root.tsx +++ b/apps/web/core/components/core/description-versions/root.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; // plane imports -import { TDescriptionVersionDetails, TDescriptionVersionsListResponse } from "@plane/types"; +import type { TDescriptionVersionDetails, TDescriptionVersionsListResponse } from "@plane/types"; import { cn } from "@plane/utils"; // local imports import { DescriptionVersionsDropdown } from "./dropdown"; diff --git a/apps/web/core/components/core/filters/date-filter-modal.tsx b/apps/web/core/components/core/filters/date-filter-modal.tsx index 334d15ae4d..32a4074141 100644 --- a/apps/web/core/components/core/filters/date-filter-modal.tsx +++ b/apps/web/core/components/core/filters/date-filter-modal.tsx @@ -2,12 +2,12 @@ import { Fragment } from "react"; import { Controller, useForm } from "react-hook-form"; -import { X } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; import { Button } from "@plane/propel/button"; import { Calendar } from "@plane/propel/calendar"; +import { CloseIcon } from "@plane/propel/icons"; import { renderFormattedPayloadDate, renderFormattedDate, getDate } from "@plane/utils"; import { DateFilterSelect } from "./date-filter-select"; type Props = { @@ -84,7 +84,7 @@ export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, o )} /> - +
= observer((props) => { const [query, setQuery] = useState(""); const [issues, setIssues] = useState([]); const [isSearching, setIsSearching] = useState(false); + // theme hook + const { resolvedTheme } = useTheme(); // hooks const { issues: { removeBulkIssues }, @@ -50,8 +59,8 @@ export const BulkDeleteIssuesModal: React.FC = observer((props) => { const { t } = useTranslation(); // derived values const debouncedSearchTerm: string = useDebounce(query, 500); - const searchResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); - const issuesResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/issues" }); + const searchResolvedPath = resolvedTheme === "light" ? lightSearchAsset : darkSearchAsset; + const issuesResolvedPath = resolvedTheme === "light" ? lightIssuesAsset : darkIssuesAsset; useEffect(() => { if (!isOpen || !workspaceSlug || !projectId) return; diff --git a/apps/web/core/components/core/modals/existing-issues-list-modal.tsx b/apps/web/core/components/core/modals/existing-issues-list-modal.tsx index f215ff7f2f..09e12fb16e 100644 --- a/apps/web/core/components/core/modals/existing-issues-list-modal.tsx +++ b/apps/web/core/components/core/modals/existing-issues-list-modal.tsx @@ -1,15 +1,16 @@ "use client"; -import React, { useEffect, useState } from "react"; -import { Rocket, Search, X } from "lucide-react"; +import React, { useEffect, useState, useRef } from "react"; +import { Rocket, Search } from "lucide-react"; import { Combobox, Dialog, Transition } from "@headlessui/react"; // i18n import { useTranslation } from "@plane/i18n"; // types import { Button } from "@plane/propel/button"; +import { CloseIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import { ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types"; +import type { ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types"; // ui import { Loader, ToggleSwitch } from "@plane/ui"; import { generateWorkItemLink, getTabIndex } from "@plane/utils"; @@ -33,7 +34,7 @@ type Props = { handleOnSubmit: (data: ISearchIssueResponse[]) => Promise; workspaceLevelToggle?: boolean; shouldHideIssue?: (issue: ISearchIssueResponse) => boolean; - selectedWorkItems?: ISearchIssueResponse[]; + selectedWorkItemIds?: string[]; workItemSearchServiceCallback?: (params: TProjectIssuesSearchParams) => Promise; }; @@ -51,7 +52,7 @@ export const ExistingIssuesListModal: React.FC = (props) => { handleOnSubmit, workspaceLevelToggle = false, shouldHideIssue, - selectedWorkItems, + selectedWorkItemIds, workItemSearchServiceCallback, } = props; // states @@ -65,12 +66,14 @@ export const ExistingIssuesListModal: React.FC = (props) => { const { isMobile } = usePlatformOS(); const debouncedSearchTerm: string = useDebounce(searchTerm, 500); const { baseTabIndex } = getTabIndex(undefined, isMobile); + const hasInitializedSelection = useRef(false); const handleClose = () => { onClose(); setSearchTerm(""); setSelectedIssues([]); setIsWorkspaceLevel(false); + hasInitializedSelection.current = false; }; const onSubmit = async () => { @@ -117,10 +120,11 @@ export const ExistingIssuesListModal: React.FC = (props) => { }; useEffect(() => { - if (selectedWorkItems) { - setSelectedIssues(selectedWorkItems); + if (isOpen && !hasInitializedSelection.current && selectedWorkItemIds && issues.length > 0) { + setSelectedIssues(issues.filter((issue) => selectedWorkItemIds.includes(issue.id))); + hasInitializedSelection.current = true; } - }, [isOpen, selectedWorkItems]); + }, [isOpen, issues, selectedWorkItemIds]); useEffect(() => { handleSearch(); @@ -197,7 +201,7 @@ export const ExistingIssuesListModal: React.FC = (props) => { className="group p-1" onClick={() => setSelectedIssues((prevData) => prevData.filter((i) => i.id !== issue.id))} > - +
))} diff --git a/apps/web/core/components/core/modals/gpt-assistant-popover.tsx b/apps/web/core/components/core/modals/gpt-assistant-popover.tsx index a1a513b0fa..d157be3d76 100644 --- a/apps/web/core/components/core/modals/gpt-assistant-popover.tsx +++ b/apps/web/core/components/core/modals/gpt-assistant-popover.tsx @@ -1,7 +1,8 @@ "use client"; -import React, { useEffect, useState, useRef, Fragment, Ref } from "react"; -import { Placement } from "@popperjs/core"; +import type { Ref } from "react"; +import React, { useEffect, useState, useRef, Fragment } from "react"; +import type { Placement } from "@popperjs/core"; import { Controller, useForm } from "react-hook-form"; // services import { usePopper } from "react-popper"; import { AlertCircle } from "lucide-react"; diff --git a/apps/web/core/components/core/modals/issue-search-modal-empty-state.tsx b/apps/web/core/components/core/modals/issue-search-modal-empty-state.tsx index b94aa90533..ede002b531 100644 --- a/apps/web/core/components/core/modals/issue-search-modal-empty-state.tsx +++ b/apps/web/core/components/core/modals/issue-search-modal-empty-state.tsx @@ -1,10 +1,15 @@ import React from "react"; +import { useTheme } from "next-themes"; // plane imports import { useTranslation } from "@plane/i18n"; -import { ISearchIssueResponse } from "@plane/types"; +import type { ISearchIssueResponse } from "@plane/types"; +// assets +import darkIssuesAsset from "@/app/assets/empty-state/search/issues-dark.webp?url"; +import lightIssuesAsset from "@/app/assets/empty-state/search/issues-light.webp?url"; +import darkSearchAsset from "@/app/assets/empty-state/search/search-dark.webp?url"; +import lightSearchAsset from "@/app/assets/empty-state/search/search-light.webp?url"; // components import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; interface EmptyStateProps { issues: ISearchIssueResponse[]; @@ -19,11 +24,13 @@ export const IssueSearchModalEmptyState: React.FC = ({ debouncedSearchTerm, isSearching, }) => { + // theme hook + const { resolvedTheme } = useTheme(); // plane hooks const { t } = useTranslation(); // derived values - const searchResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); - const issuesResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/issues" }); + const searchResolvedPath = resolvedTheme === "light" ? lightSearchAsset : darkSearchAsset; + const issuesResolvedPath = resolvedTheme === "light" ? lightIssuesAsset : darkIssuesAsset; const EmptyStateContainer = ({ children }: { children: React.ReactNode }) => (
{children}
diff --git a/apps/web/core/components/core/modals/user-image-upload-modal.tsx b/apps/web/core/components/core/modals/user-image-upload-modal.tsx index cade520966..e12ca566e0 100644 --- a/apps/web/core/components/core/modals/user-image-upload-modal.tsx +++ b/apps/web/core/components/core/modals/user-image-upload-modal.tsx @@ -3,11 +3,11 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; import { useDropzone } from "react-dropzone"; -import { UserCircle2 } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; // plane imports import { ACCEPTED_AVATAR_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, MAX_FILE_SIZE } from "@plane/constants"; import { Button } from "@plane/propel/button"; +import { UserCirclePropertyIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { EFileAssetType } from "@plane/types"; import { getAssetIdFromUrl, getFileURL, checkURLValidity } from "@plane/utils"; @@ -146,7 +146,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { ) : (
- + {isDragActive ? "Drop image here to upload" : "Drag & drop image here"} diff --git a/apps/web/core/components/core/modals/workspace-image-upload-modal.tsx b/apps/web/core/components/core/modals/workspace-image-upload-modal.tsx index 496319f752..decf804700 100644 --- a/apps/web/core/components/core/modals/workspace-image-upload-modal.tsx +++ b/apps/web/core/components/core/modals/workspace-image-upload-modal.tsx @@ -3,11 +3,11 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { useDropzone } from "react-dropzone"; -import { UserCircle2 } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; // plane imports import { ACCEPTED_AVATAR_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, MAX_FILE_SIZE } from "@plane/constants"; import { Button } from "@plane/propel/button"; +import { UserCirclePropertyIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { EFileAssetType } from "@plane/types"; import { getAssetIdFromUrl, getFileURL, checkURLValidity } from "@plane/utils"; @@ -158,7 +158,7 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { ) : (
- + {isDragActive ? "Drop image here to upload" : "Drag & drop image here"} diff --git a/apps/web/core/components/core/multiple-select/entity-select-action.tsx b/apps/web/core/components/core/multiple-select/entity-select-action.tsx index 13795db938..9fc45cc504 100644 --- a/apps/web/core/components/core/multiple-select/entity-select-action.tsx +++ b/apps/web/core/components/core/multiple-select/entity-select-action.tsx @@ -6,7 +6,7 @@ import { Checkbox } from "@plane/ui"; // helpers import { cn } from "@plane/utils"; // hooks -import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; type Props = { className?: string; diff --git a/apps/web/core/components/core/multiple-select/group-select-action.tsx b/apps/web/core/components/core/multiple-select/group-select-action.tsx index 3040f2d1ec..78b106d0fd 100644 --- a/apps/web/core/components/core/multiple-select/group-select-action.tsx +++ b/apps/web/core/components/core/multiple-select/group-select-action.tsx @@ -4,7 +4,7 @@ import { Checkbox } from "@plane/ui"; // helpers import { cn } from "@plane/utils"; // hooks -import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; type Props = { className?: string; diff --git a/apps/web/core/components/core/multiple-select/select-group.tsx b/apps/web/core/components/core/multiple-select/select-group.tsx index 6110642d11..7f3aec71e7 100644 --- a/apps/web/core/components/core/multiple-select/select-group.tsx +++ b/apps/web/core/components/core/multiple-select/select-group.tsx @@ -1,6 +1,7 @@ import { observer } from "mobx-react"; // hooks -import { TSelectionHelper, useMultipleSelect } from "@/hooks/use-multiple-select"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; +import { useMultipleSelect } from "@/hooks/use-multiple-select"; type Props = { children: (helpers: TSelectionHelper) => React.ReactNode; diff --git a/apps/web/core/components/core/render-if-visible-HOC.tsx b/apps/web/core/components/core/render-if-visible-HOC.tsx index f00cf78ee0..9e77113a8e 100644 --- a/apps/web/core/components/core/render-if-visible-HOC.tsx +++ b/apps/web/core/components/core/render-if-visible-HOC.tsx @@ -1,4 +1,5 @@ -import React, { useState, useRef, useEffect, ReactNode, MutableRefObject } from "react"; +import type { ReactNode, MutableRefObject } from "react"; +import React, { useState, useRef, useEffect } from "react"; import { cn } from "@plane/utils"; type Props = { diff --git a/apps/web/core/components/core/sidebar/progress-chart.tsx b/apps/web/core/components/core/sidebar/progress-chart.tsx index ac1f1cb5ad..135454cf4e 100644 --- a/apps/web/core/components/core/sidebar/progress-chart.tsx +++ b/apps/web/core/components/core/sidebar/progress-chart.tsx @@ -1,7 +1,7 @@ import React from "react"; // plane imports import { AreaChart } from "@plane/propel/charts/area-chart"; -import { TChartData, TModuleCompletionChartDistribution } from "@plane/types"; +import type { TChartData, TModuleCompletionChartDistribution } from "@plane/types"; import { renderFormattedDateWithoutYear } from "@plane/utils"; type Props = { diff --git a/apps/web/core/components/core/sidebar/progress-stats/assignee.tsx b/apps/web/core/components/core/sidebar/progress-stats/assignee.tsx index dbb4dcebb0..c3607eafc2 100644 --- a/apps/web/core/components/core/sidebar/progress-stats/assignee.tsx +++ b/apps/web/core/components/core/sidebar/progress-stats/assignee.tsx @@ -4,10 +4,11 @@ import Image from "next/image"; import { useTranslation } from "@plane/i18n"; import { Avatar } from "@plane/ui"; import { getFileURL } from "@plane/utils"; +// assets +import emptyMembers from "@/app/assets/empty-state/empty_members.svg?url"; +import userImage from "@/app/assets/user.png?url"; // components import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats"; -// public -import emptyMembers from "@/public/empty-state/empty_members.svg"; export type TAssigneeData = { id: string | undefined; @@ -56,7 +57,7 @@ export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => title={
- User + User
{t("no_assignee")}
diff --git a/apps/web/core/components/core/sidebar/progress-stats/label.tsx b/apps/web/core/components/core/sidebar/progress-stats/label.tsx index 3897ec13e1..df510a73f2 100644 --- a/apps/web/core/components/core/sidebar/progress-stats/label.tsx +++ b/apps/web/core/components/core/sidebar/progress-stats/label.tsx @@ -2,10 +2,10 @@ import { observer } from "mobx-react"; import Image from "next/image"; // plane imports import { useTranslation } from "@plane/i18n"; +// assets +import emptyLabel from "@/app/assets/empty-state/empty_label.svg?url"; // components import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats"; -// public -import emptyLabel from "@/public/empty-state/empty_label.svg"; export type TLabelData = { id: string | undefined; diff --git a/apps/web/core/components/core/sidebar/progress-stats/shared.ts b/apps/web/core/components/core/sidebar/progress-stats/shared.ts index b0b549cd56..38f2352f35 100644 --- a/apps/web/core/components/core/sidebar/progress-stats/shared.ts +++ b/apps/web/core/components/core/sidebar/progress-stats/shared.ts @@ -1,5 +1,5 @@ -import { TWorkItemFilterCondition } from "@plane/shared-state"; -import { TFilterConditionNodeForDisplay, TFilterValue, TWorkItemFilterProperty } from "@plane/types"; +import type { TWorkItemFilterCondition } from "@plane/shared-state"; +import type { TFilterConditionNodeForDisplay, TFilterValue, TWorkItemFilterProperty } from "@plane/types"; export const PROGRESS_STATS = [ { diff --git a/apps/web/core/components/core/sidebar/progress-stats/state_group.tsx b/apps/web/core/components/core/sidebar/progress-stats/state_group.tsx index 8aee134405..317d426d6b 100644 --- a/apps/web/core/components/core/sidebar/progress-stats/state_group.tsx +++ b/apps/web/core/components/core/sidebar/progress-stats/state_group.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react"; // plane imports import { StateGroupIcon } from "@plane/propel/icons"; -import { TStateGroups } from "@plane/types"; +import type { TStateGroups } from "@plane/types"; // components import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats"; diff --git a/apps/web/core/components/core/theme/color-picker-input.tsx b/apps/web/core/components/core/theme/color-picker-input.tsx index dbaa13aacb..be9bab9d59 100644 --- a/apps/web/core/components/core/theme/color-picker-input.tsx +++ b/apps/web/core/components/core/theme/color-picker-input.tsx @@ -1,10 +1,11 @@ "use client"; -import { FC, Fragment } from "react"; +import type { FC } from "react"; +import { Fragment } from "react"; // react-form -import { ColorResult, SketchPicker } from "react-color"; -import { +import type { ColorResult } from "react-color"; +import { SketchPicker } from "react-color"; +import type { Control, - Controller, FieldError, FieldErrorsImpl, Merge, @@ -12,11 +13,12 @@ import { UseFormSetValue, UseFormWatch, } from "react-hook-form"; +import { Controller } from "react-hook-form"; // react-color // component import { Palette } from "lucide-react"; import { Popover, Transition } from "@headlessui/react"; -import { IUserTheme } from "@plane/types"; +import type { IUserTheme } from "@plane/types"; import { Input } from "@plane/ui"; // icons // types diff --git a/apps/web/core/components/core/theme/custom-theme-selector.tsx b/apps/web/core/components/core/theme/custom-theme-selector.tsx index ca5f376cec..c9284755ea 100644 --- a/apps/web/core/components/core/theme/custom-theme-selector.tsx +++ b/apps/web/core/components/core/theme/custom-theme-selector.tsx @@ -8,7 +8,7 @@ import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } fr import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { setPromiseToast } from "@plane/propel/toast"; -import { IUserTheme } from "@plane/types"; +import type { IUserTheme } from "@plane/types"; // ui import { InputColorPicker } from "@plane/ui"; // hooks diff --git a/apps/web/core/components/core/theme/theme-switch.tsx b/apps/web/core/components/core/theme/theme-switch.tsx index 1a188fa03e..29a2efe968 100644 --- a/apps/web/core/components/core/theme/theme-switch.tsx +++ b/apps/web/core/components/core/theme/theme-switch.tsx @@ -1,8 +1,9 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // plane imports -import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants"; +import type { I_THEME_OPTION } from "@plane/constants"; +import { THEME_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // constants import { CustomSelect } from "@plane/ui"; diff --git a/apps/web/core/components/cycles/active-cycle/cycle-stats.tsx b/apps/web/core/components/cycles/active-cycle/cycle-stats.tsx index e25df30083..4f6ffe7251 100644 --- a/apps/web/core/components/cycles/active-cycle/cycle-stats.tsx +++ b/apps/web/core/components/cycles/active-cycle/cycle-stats.tsx @@ -1,8 +1,10 @@ "use client"; -import { FC, Fragment, useCallback, useRef, useState } from "react"; +import type { FC } from "react"; +import { Fragment, useCallback, useRef, useState } from "react"; import { isEmpty } from "lodash-es"; import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; import { CalendarCheck } from "lucide-react"; // headless ui import { Tab } from "@headlessui/react"; @@ -10,26 +12,33 @@ import { Tab } from "@headlessui/react"; import { useTranslation } from "@plane/i18n"; import { PriorityIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { TWorkItemFilterCondition } from "@plane/shared-state"; -import { EIssuesStoreType, ICycle } from "@plane/types"; +import type { TWorkItemFilterCondition } from "@plane/shared-state"; +import type { ICycle } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; // ui import { Loader, Avatar } from "@plane/ui"; import { cn, renderFormattedDate, renderFormattedDateWithoutYear, getFileURL } from "@plane/utils"; +// assets +import darkAssigneeAsset from "@/app/assets/empty-state/active-cycle/assignee-dark.webp?url"; +import lightAssigneeAsset from "@/app/assets/empty-state/active-cycle/assignee-light.webp?url"; +import darkLabelAsset from "@/app/assets/empty-state/active-cycle/label-dark.webp?url"; +import lightLabelAsset from "@/app/assets/empty-state/active-cycle/label-light.webp?url"; +import darkPriorityAsset from "@/app/assets/empty-state/active-cycle/priority-dark.webp?url"; +import lightPriorityAsset from "@/app/assets/empty-state/active-cycle/priority-light.webp?url"; +import userImage from "@/app/assets/user.png?url"; // components import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats"; import { StateDropdown } from "@/components/dropdowns/state/dropdown"; import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; -// helpers // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useIssues } from "@/hooks/store/use-issues"; import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; import useLocalStorage from "@/hooks/use-local-storage"; // plane web components -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; // store -import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; +import type { ActiveCycleIssueDetails } from "@/store/issue/cycle"; export type ActiveCycleStatsProps = { workspaceSlug: string; @@ -48,12 +57,14 @@ export const ActiveCycleStats: FC = observer((props) => { const issuesContainerRef = useRef(null); // states const [issuesLoaderElement, setIssueLoaderElement] = useState(null); + // theme hook + const { resolvedTheme } = useTheme(); // plane hooks const { t } = useTranslation(); // derived values - const priorityResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/priority" }); - const assigneesResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/assignee" }); - const labelsResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/label" }); + const priorityResolvedPath = resolvedTheme === "light" ? lightPriorityAsset : darkPriorityAsset; + const assigneesResolvedPath = resolvedTheme === "light" ? lightAssigneeAsset : darkAssigneeAsset; + const labelsResolvedPath = resolvedTheme === "light" ? lightLabelAsset : darkLabelAsset; const currentValue = (tab: string | null) => { switch (tab) { @@ -292,7 +303,7 @@ export const ActiveCycleStats: FC = observer((props) => { title={
- User + User
{t("no_assignee")}
diff --git a/apps/web/core/components/cycles/active-cycle/productivity.tsx b/apps/web/core/components/cycles/active-cycle/productivity.tsx index 63d0850984..5663ff9e6c 100644 --- a/apps/web/core/components/cycles/active-cycle/productivity.tsx +++ b/apps/web/core/components/cycles/active-cycle/productivity.tsx @@ -1,17 +1,20 @@ -import { FC, Fragment } from "react"; +import type { FC } from "react"; +import { Fragment } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; +import { useTheme } from "next-themes"; // plane imports import { useTranslation } from "@plane/i18n"; -import { ICycle, TCycleEstimateType } from "@plane/types"; +import type { ICycle, TCycleEstimateType } from "@plane/types"; import { Loader } from "@plane/ui"; +// assets +import darkChartAsset from "@/app/assets/empty-state/active-cycle/chart-dark.webp?url"; +import lightChartAsset from "@/app/assets/empty-state/active-cycle/chart-light.webp?url"; // components import ProgressChart from "@/components/core/sidebar/progress-chart"; import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; -// constants +// hooks import { useCycle } from "@/hooks/store/use-cycle"; -// plane web constants -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { EstimateTypeDropdown } from "../dropdowns/estimate-type-dropdown"; export type ActiveCycleProductivityProps = { @@ -22,13 +25,15 @@ export type ActiveCycleProductivityProps = { export const ActiveCycleProductivity: FC = observer((props) => { const { workspaceSlug, projectId, cycle } = props; + // theme hook + const { resolvedTheme } = useTheme(); // plane hooks const { t } = useTranslation(); // hooks const { getEstimateTypeByCycleId, setEstimateType } = useCycle(); // derived values const estimateType: TCycleEstimateType = (cycle && getEstimateTypeByCycleId(cycle.id)) || "issues"; - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/chart" }); + const resolvedPath = resolvedTheme === "light" ? lightChartAsset : darkChartAsset; const onChange = async (value: TCycleEstimateType) => { if (!workspaceSlug || !projectId || !cycle || !cycle.id) return; diff --git a/apps/web/core/components/cycles/active-cycle/progress.tsx b/apps/web/core/components/cycles/active-cycle/progress.tsx index db1a7721b1..1e2812d606 100644 --- a/apps/web/core/components/cycles/active-cycle/progress.tsx +++ b/apps/web/core/components/cycles/active-cycle/progress.tsx @@ -1,17 +1,19 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; // plane imports import { PROGRESS_STATE_GROUPS_DETAILS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TWorkItemFilterCondition } from "@plane/shared-state"; -import { ICycle } from "@plane/types"; +import type { TWorkItemFilterCondition } from "@plane/shared-state"; +import type { ICycle } from "@plane/types"; import { LinearProgressIndicator, Loader } from "@plane/ui"; +// assets +import darkProgressAsset from "@/app/assets/empty-state/active-cycle/progress-dark.webp?url"; +import lightProgressAsset from "@/app/assets/empty-state/active-cycle/progress-light.webp?url"; // components import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; -// hooks -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; export type ActiveCycleProgressProps = { cycle: ICycle | null; @@ -22,6 +24,8 @@ export type ActiveCycleProgressProps = { export const ActiveCycleProgress: FC = observer((props) => { const { handleFiltersUpdate, cycle } = props; + // theme hook + const { resolvedTheme } = useTheme(); // plane hooks const { t } = useTranslation(); // derived values @@ -39,7 +43,7 @@ export const ActiveCycleProgress: FC = observer((props backlog: cycle?.backlog_issues, } : {}; - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/progress" }); + const resolvedPath = resolvedTheme === "light" ? lightProgressAsset : darkProgressAsset; return cycle && cycle.hasOwnProperty("started_issues") ? (
diff --git a/apps/web/core/components/cycles/active-cycle/use-cycles-details.ts b/apps/web/core/components/cycles/active-cycle/use-cycles-details.ts index 54a3b506e5..7f9154320f 100644 --- a/apps/web/core/components/cycles/active-cycle/use-cycles-details.ts +++ b/apps/web/core/components/cycles/active-cycle/use-cycles-details.ts @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { useRouter } from "next/navigation"; import useSWR from "swr"; // plane imports -import { TWorkItemFilterCondition } from "@plane/shared-state"; +import type { TWorkItemFilterCondition } from "@plane/shared-state"; import { EIssuesStoreType } from "@plane/types"; // constants import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys"; diff --git a/apps/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/apps/web/core/components/cycles/analytics-sidebar/issue-progress.tsx index 22337ce26f..100311e78a 100644 --- a/apps/web/core/components/cycles/analytics-sidebar/issue-progress.tsx +++ b/apps/web/core/components/cycles/analytics-sidebar/issue-progress.tsx @@ -1,14 +1,16 @@ "use client"; -import { FC, useMemo } from "react"; +import type { FC } from "react"; +import { useMemo } from "react"; import { isEmpty } from "lodash-es"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; -import { ChevronUp, ChevronDown } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { EIssuesStoreType, ICycle, TCyclePlotType, TProgressSnapshot } from "@plane/types"; +import { ChevronUpIcon, ChevronDownIcon } from "@plane/propel/icons"; +import type { ICycle, TCyclePlotType, TProgressSnapshot } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; import { getDate } from "@plane/utils"; // hooks import { useCycle } from "@/hooks/store/use-cycle"; @@ -112,9 +114,9 @@ export const CycleAnalyticsProgress: FC = observer((pro {open ? ( -
diff --git a/apps/web/core/components/cycles/analytics-sidebar/progress-stats.tsx b/apps/web/core/components/cycles/analytics-sidebar/progress-stats.tsx index eaa60b6bd4..2072a30df2 100644 --- a/apps/web/core/components/cycles/analytics-sidebar/progress-stats.tsx +++ b/apps/web/core/components/cycles/analytics-sidebar/progress-stats.tsx @@ -1,22 +1,22 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { Tab } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TWorkItemFilterCondition } from "@plane/shared-state"; -import { TCycleDistribution, TCycleEstimateDistribution, TCyclePlotType } from "@plane/types"; +import type { TWorkItemFilterCondition } from "@plane/shared-state"; +import type { TCycleDistribution, TCycleEstimateDistribution, TCyclePlotType } from "@plane/types"; import { cn, toFilterArray } from "@plane/utils"; // components -import { AssigneeStatComponent, TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee"; -import { LabelStatComponent, TLabelData } from "@/components/core/sidebar/progress-stats/label"; -import { - createFilterUpdateHandler, - PROGRESS_STATS, - TSelectedFilterProgressStats, -} from "@/components/core/sidebar/progress-stats/shared"; -import { StateGroupStatComponent, TStateGroupData } from "@/components/core/sidebar/progress-stats/state_group"; +import type { TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee"; +import { AssigneeStatComponent } from "@/components/core/sidebar/progress-stats/assignee"; +import type { TLabelData } from "@/components/core/sidebar/progress-stats/label"; +import { LabelStatComponent } from "@/components/core/sidebar/progress-stats/label"; +import type { TSelectedFilterProgressStats } from "@/components/core/sidebar/progress-stats/shared"; +import { createFilterUpdateHandler, PROGRESS_STATS } from "@/components/core/sidebar/progress-stats/shared"; +import type { TStateGroupData } from "@/components/core/sidebar/progress-stats/state_group"; +import { StateGroupStatComponent } from "@/components/core/sidebar/progress-stats/state_group"; // helpers // hooks import useLocalStorage from "@/hooks/use-local-storage"; diff --git a/apps/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx b/apps/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx index d53e9da1a2..7c6e6b4edb 100644 --- a/apps/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx +++ b/apps/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx @@ -1,13 +1,14 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { isEmpty } from "lodash-es"; import { observer } from "mobx-react"; -import { SquareUser, Users } from "lucide-react"; +import { SquareUser } from "lucide-react"; // plane types import { EEstimateSystem } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { WorkItemsIcon } from "@plane/propel/icons"; -import { ICycle } from "@plane/types"; +import { MembersPropertyIcon, WorkItemsIcon } from "@plane/propel/icons"; +import type { ICycle } from "@plane/types"; // plane ui import { Avatar, AvatarGroup, TextArea } from "@plane/ui"; // helpers @@ -86,7 +87,7 @@ export const CycleSidebarDetails: FC = observer((props) => {
- + {t("members")}
diff --git a/apps/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx b/apps/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx index 41aae0cba5..41129e9c07 100644 --- a/apps/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx +++ b/apps/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { FC, useEffect } from "react"; +import type { FC } from "react"; +import React, { useEffect } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; -import { ArrowRight, ChevronRight } from "lucide-react"; +import { ArrowRight } from "lucide-react"; // Plane Imports import { CYCLE_TRACKER_EVENTS, @@ -13,8 +14,9 @@ import { CYCLE_TRACKER_ELEMENTS, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { ChevronRightIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { ICycle } from "@plane/types"; +import type { ICycle } from "@plane/types"; import { getDate, renderFormattedPayloadDate } from "@plane/utils"; // components import { DateRangeDropdown } from "@/components/dropdowns/date-range"; @@ -159,7 +161,7 @@ export const CycleSidebarHeader: FC = observer((props) => { className="flex size-4 items-center justify-center rounded-full bg-custom-border-200" onClick={() => handleClose()} > - +
diff --git a/apps/web/core/components/cycles/applied-filters/date.tsx b/apps/web/core/components/cycles/applied-filters/date.tsx index 18a5ece58a..e8156fafc6 100644 --- a/apps/web/core/components/cycles/applied-filters/date.tsx +++ b/apps/web/core/components/cycles/applied-filters/date.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react"; -import { X } from "lucide-react"; // helpers import { DATE_AFTER_FILTER_OPTIONS } from "@plane/constants"; +import { CloseIcon } from "@plane/propel/icons"; import { renderFormattedDate, capitalizeFirstLetter } from "@plane/utils"; // constants @@ -44,7 +44,7 @@ export const AppliedDateFilters: React.FC = observer((props) => { className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" onClick={() => handleRemove(date)} > - + )}
diff --git a/apps/web/core/components/cycles/applied-filters/root.tsx b/apps/web/core/components/cycles/applied-filters/root.tsx index 77fa8f1605..59ebf55cb0 100644 --- a/apps/web/core/components/cycles/applied-filters/root.tsx +++ b/apps/web/core/components/cycles/applied-filters/root.tsx @@ -1,9 +1,9 @@ import { observer } from "mobx-react"; -import { X } from "lucide-react"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TCycleFilters } from "@plane/types"; +import { CloseIcon } from "@plane/propel/icons"; +import type { TCycleFilters } from "@plane/types"; import { Tag } from "@plane/ui"; import { replaceUnderscoreIfSnakeCase } from "@plane/utils"; // hooks @@ -67,7 +67,7 @@ export const CycleAppliedFiltersList: React.FC = observer((props) => { className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" onClick={() => handleRemoveFilter(filterKey, null)} > - + )}
@@ -78,7 +78,7 @@ export const CycleAppliedFiltersList: React.FC = observer((props) => { )} diff --git a/apps/web/core/components/cycles/applied-filters/status.tsx b/apps/web/core/components/cycles/applied-filters/status.tsx index ef2d63b1a3..e54edf6b70 100644 --- a/apps/web/core/components/cycles/applied-filters/status.tsx +++ b/apps/web/core/components/cycles/applied-filters/status.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react"; -import { X } from "lucide-react"; import { CYCLE_STATUS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { CloseIcon } from "@plane/propel/icons"; import { cn } from "@plane/utils"; type Props = { @@ -34,7 +34,7 @@ export const AppliedStatusFilters: React.FC = observer((props) => { className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" onClick={() => handleRemove(status)} > - + )} diff --git a/apps/web/core/components/cycles/archived-cycles/header.tsx b/apps/web/core/components/cycles/archived-cycles/header.tsx index 15a2b4c6da..50e8fd67a4 100644 --- a/apps/web/core/components/cycles/archived-cycles/header.tsx +++ b/apps/web/core/components/cycles/archived-cycles/header.tsx @@ -1,10 +1,12 @@ -import { FC, useCallback, useRef, useState } from "react"; +import type { FC } from "react"; +import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons -import { ListFilter, Search, X } from "lucide-react"; -// plane helpers +import { ListFilter, Search } from "lucide-react"; import { useOutsideClickDetector } from "@plane/hooks"; +import { CloseIcon } from "@plane/propel/icons"; +// plane helpers // types import type { TCycleFilters } from "@plane/types"; import { cn, calculateTotalFilters } from "@plane/utils"; @@ -108,7 +110,7 @@ export const ArchivedCyclesHeader: FC = observer(() => { setIsSearchOpen(false); }} > - + )} diff --git a/apps/web/core/components/cycles/archived-cycles/root.tsx b/apps/web/core/components/cycles/archived-cycles/root.tsx index ca8c8dc5bf..a171b9aa52 100644 --- a/apps/web/core/components/cycles/archived-cycles/root.tsx +++ b/apps/web/core/components/cycles/archived-cycles/root.tsx @@ -4,15 +4,14 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TCycleFilters } from "@plane/types"; +import { EmptyStateDetailed } from "@plane/propel/empty-state"; +import type { TCycleFilters } from "@plane/types"; import { calculateTotalFilters } from "@plane/utils"; // components -import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader"; // hooks import { useCycle } from "@/hooks/store/use-cycle"; import { useCycleFilter } from "@/hooks/store/use-cycle-filter"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; // local imports import { CycleAppliedFiltersList } from "../applied-filters"; import { ArchivedCyclesView } from "./view"; @@ -28,7 +27,6 @@ export const ArchivedCycleLayoutRoot: React.FC = observer(() => { const { clearAllFilters, currentProjectArchivedFilters, updateFilters } = useCycleFilter(); // derived values const totalArchivedCycles = currentProjectArchivedCycleIds?.length ?? 0; - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/archived/empty-cycles" }); useSWR( workspaceSlug && projectId ? `ARCHIVED_CYCLES_${workspaceSlug.toString()}_${projectId.toString()}` : null, @@ -69,10 +67,10 @@ export const ArchivedCycleLayoutRoot: React.FC = observer(() => { )} {totalArchivedCycles === 0 ? (
-
) : ( diff --git a/apps/web/core/components/cycles/archived-cycles/view.tsx b/apps/web/core/components/cycles/archived-cycles/view.tsx index fd0c8fe41d..9491dc0f68 100644 --- a/apps/web/core/components/cycles/archived-cycles/view.tsx +++ b/apps/web/core/components/cycles/archived-cycles/view.tsx @@ -1,6 +1,9 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; +// assets +import AllFiltersImage from "@/app/assets/empty-state/cycle/all-filters.svg?url"; +import NameFilterImage from "@/app/assets/empty-state/cycle/name-filter.svg?url"; // components import { CyclesList } from "@/components/cycles/list"; // ui @@ -8,9 +11,6 @@ import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module // hooks import { useCycle } from "@/hooks/store/use-cycle"; import { useCycleFilter } from "@/hooks/store/use-cycle-filter"; -// assets -import AllFiltersImage from "@/public/empty-state/cycle/all-filters.svg"; -import NameFilterImage from "@/public/empty-state/cycle/name-filter.svg"; export interface IArchivedCyclesView { workspaceSlug: string; diff --git a/apps/web/core/components/cycles/cycles-view-header.tsx b/apps/web/core/components/cycles/cycles-view-header.tsx index 4976a33f51..1cc782f59c 100644 --- a/apps/web/core/components/cycles/cycles-view-header.tsx +++ b/apps/web/core/components/cycles/cycles-view-header.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; // icons -import { ListFilter, Search, X } from "lucide-react"; +import { ListFilter, Search } from "lucide-react"; // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; // types import { useTranslation } from "@plane/i18n"; -import { TCycleFilters } from "@plane/types"; +import { CloseIcon } from "@plane/propel/icons"; +import type { TCycleFilters } from "@plane/types"; import { cn, calculateTotalFilters } from "@plane/utils"; // components import { FiltersDropdown } from "@/components/issues/issue-layouts/filters"; @@ -109,7 +110,7 @@ export const CyclesViewHeader: React.FC = observer((props) => { setIsSearchOpen(false); }} > - + )} diff --git a/apps/web/core/components/cycles/cycles-view.tsx b/apps/web/core/components/cycles/cycles-view.tsx index a15596b173..f0958d213c 100644 --- a/apps/web/core/components/cycles/cycles-view.tsx +++ b/apps/web/core/components/cycles/cycles-view.tsx @@ -1,17 +1,17 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; // components import { useTranslation } from "@plane/i18n"; +// assets +import AllFiltersImage from "@/app/assets/empty-state/cycle/all-filters.svg?url"; +import NameFilterImage from "@/app/assets/empty-state/cycle/name-filter.svg?url"; +// components import { CyclesList } from "@/components/cycles/list"; -// ui import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader"; // hooks import { useCycle } from "@/hooks/store/use-cycle"; import { useCycleFilter } from "@/hooks/store/use-cycle-filter"; -// assets -import AllFiltersImage from "@/public/empty-state/cycle/all-filters.svg"; -import NameFilterImage from "@/public/empty-state/cycle/name-filter.svg"; export interface ICyclesView { workspaceSlug: string; diff --git a/apps/web/core/components/cycles/delete-modal.tsx b/apps/web/core/components/cycles/delete-modal.tsx index 099bdba05f..2a4067252d 100644 --- a/apps/web/core/components/cycles/delete-modal.tsx +++ b/apps/web/core/components/cycles/delete-modal.tsx @@ -7,7 +7,7 @@ import { useParams, useSearchParams } from "next/navigation"; import { PROJECT_ERROR_MESSAGES, CYCLE_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { ICycle } from "@plane/types"; +import type { ICycle } from "@plane/types"; // ui import { AlertModalCore } from "@plane/ui"; // helpers diff --git a/apps/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx b/apps/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx index 278d0fb128..56761b3ee1 100644 --- a/apps/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx +++ b/apps/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx @@ -1,6 +1,7 @@ import React from "react"; import { observer } from "mobx-react"; -import { EEstimateSystem, TCycleEstimateType } from "@plane/types"; +import type { TCycleEstimateType } from "@plane/types"; +import { EEstimateSystem } from "@plane/types"; import { CustomSelect } from "@plane/ui"; import { useProjectEstimates } from "@/hooks/store/estimates"; import { useCycle } from "@/hooks/store/use-cycle"; diff --git a/apps/web/core/components/cycles/dropdowns/filters/root.tsx b/apps/web/core/components/cycles/dropdowns/filters/root.tsx index ff32a4b23b..148de99463 100644 --- a/apps/web/core/components/cycles/dropdowns/filters/root.tsx +++ b/apps/web/core/components/cycles/dropdowns/filters/root.tsx @@ -1,8 +1,9 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { Search, X } from "lucide-react"; +import { Search } from "lucide-react"; +import { CloseIcon } from "@plane/propel/icons"; // plane imports -import { TCycleFilters, TCycleGroups } from "@plane/types"; +import type { TCycleFilters, TCycleGroups } from "@plane/types"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; // local imports @@ -38,7 +39,7 @@ export const CycleFiltersSelection: React.FC = observer((props) => { /> {filtersSearchQuery !== "" && ( )} diff --git a/apps/web/core/components/cycles/dropdowns/filters/status.tsx b/apps/web/core/components/cycles/dropdowns/filters/status.tsx index 70cde0b668..482aaabd64 100644 --- a/apps/web/core/components/cycles/dropdowns/filters/status.tsx +++ b/apps/web/core/components/cycles/dropdowns/filters/status.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; import { CYCLE_STATUS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TCycleGroups } from "@plane/types"; +import type { TCycleGroups } from "@plane/types"; // components import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; // types diff --git a/apps/web/core/components/cycles/form.tsx b/apps/web/core/components/cycles/form.tsx index 86122719e6..8206501c94 100644 --- a/apps/web/core/components/cycles/form.tsx +++ b/apps/web/core/components/cycles/form.tsx @@ -7,7 +7,7 @@ import { ETabIndices } from "@plane/constants"; // types import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; -import { ICycle } from "@plane/types"; +import type { ICycle } from "@plane/types"; // ui import { Input, TextArea } from "@plane/ui"; import { getDate, renderFormattedPayloadDate, getTabIndex } from "@plane/utils"; diff --git a/apps/web/core/components/cycles/list/cycle-list-group-header.tsx b/apps/web/core/components/cycles/list/cycle-list-group-header.tsx index 11e2f3968f..074bc500ce 100644 --- a/apps/web/core/components/cycles/list/cycle-list-group-header.tsx +++ b/apps/web/core/components/cycles/list/cycle-list-group-header.tsx @@ -1,10 +1,10 @@ "use client"; -import React, { FC } from "react"; -import { ChevronDown } from "lucide-react"; +import type { FC } from "react"; +import React from "react"; // types -import { CycleGroupIcon } from "@plane/propel/icons"; -import { TCycleGroups } from "@plane/types"; +import { CycleGroupIcon, ChevronDownIcon } from "@plane/propel/icons"; +import type { TCycleGroups } from "@plane/types"; // icons import { Row } from "@plane/ui"; // helpers @@ -32,7 +32,7 @@ export const CycleListGroupHeader: FC = (props) => { {showCount &&
{`${count ?? "0"}`}
} - = observer((props) => { })} ) : ( - + )} diff --git a/apps/web/core/components/cycles/list/cycle-list-project-group-header.tsx b/apps/web/core/components/cycles/list/cycle-list-project-group-header.tsx index 702edd6aa6..2642502a2f 100644 --- a/apps/web/core/components/cycles/list/cycle-list-project-group-header.tsx +++ b/apps/web/core/components/cycles/list/cycle-list-project-group-header.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; -import { ChevronRight } from "lucide-react"; +import { ChevronRightIcon } from "@plane/propel/icons"; // icons import { Row } from "@plane/ui"; // helpers @@ -28,7 +29,7 @@ export const CycleListProjectGroupHeader: FC = observer((props) => { if (!project) return null; return ( - = observer((props) => {

Transfer work items

diff --git a/apps/web/core/components/dropdowns/buttons.tsx b/apps/web/core/components/dropdowns/buttons.tsx index 245fd7ccca..b3d5355bd8 100644 --- a/apps/web/core/components/dropdowns/buttons.tsx +++ b/apps/web/core/components/dropdowns/buttons.tsx @@ -6,7 +6,7 @@ import { cn } from "@plane/utils"; // types import { usePlatformOS } from "@/hooks/use-platform-os"; import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS } from "./constants"; -import { TButtonVariants } from "./types"; +import type { TButtonVariants } from "./types"; export type DropdownButtonProps = { children: React.ReactNode; diff --git a/apps/web/core/components/dropdowns/constants.ts b/apps/web/core/components/dropdowns/constants.ts index ce52ad5054..9451b1fca9 100644 --- a/apps/web/core/components/dropdowns/constants.ts +++ b/apps/web/core/components/dropdowns/constants.ts @@ -1,5 +1,5 @@ // types -import { TButtonVariants } from "./types"; +import type { TButtonVariants } from "./types"; export const BORDER_BUTTON_VARIANTS: TButtonVariants[] = ["border-with-text", "border-without-text"]; diff --git a/apps/web/core/components/dropdowns/cycle/cycle-options.tsx b/apps/web/core/components/dropdowns/cycle/cycle-options.tsx index 2166869a49..3bde0dfca1 100644 --- a/apps/web/core/components/dropdowns/cycle/cycle-options.tsx +++ b/apps/web/core/components/dropdowns/cycle/cycle-options.tsx @@ -1,7 +1,8 @@ "use client"; -import { FC, useEffect, useRef, useState } from "react"; -import { Placement } from "@popperjs/core"; +import type { FC } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { usePopper } from "react-popper"; @@ -12,7 +13,7 @@ import { Combobox } from "@headlessui/react"; import { useTranslation } from "@plane/i18n"; // icon import { CycleGroupIcon, CycleIcon } from "@plane/propel/icons"; -import { TCycleGroups } from "@plane/types"; +import type { TCycleGroups } from "@plane/types"; // ui // store hooks import { useCycle } from "@/hooks/store/use-cycle"; diff --git a/apps/web/core/components/dropdowns/cycle/index.tsx b/apps/web/core/components/dropdowns/cycle/index.tsx index dc58bca0ce..cb2df46835 100644 --- a/apps/web/core/components/dropdowns/cycle/index.tsx +++ b/apps/web/core/components/dropdowns/cycle/index.tsx @@ -1,11 +1,11 @@ "use client"; -import { ReactNode, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { useRef, useState } from "react"; import { observer } from "mobx-react"; -import { ChevronDown } from "lucide-react"; import { useTranslation } from "@plane/i18n"; // ui -import { CycleIcon } from "@plane/propel/icons"; +import { CycleIcon, ChevronDownIcon } from "@plane/propel/icons"; import { ComboDropDown } from "@plane/ui"; // helpers import { cn } from "@plane/utils"; @@ -15,7 +15,7 @@ import { useDropdown } from "@/hooks/use-dropdown"; // local components and constants import { DropdownButton } from "../buttons"; import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; -import { TDropdownProps } from "../types"; +import type { TDropdownProps } from "../types"; import { CycleOptions } from "./cycle-options"; type Props = TDropdownProps & { @@ -125,7 +125,7 @@ export const CycleDropdown: React.FC = observer((props) => { {selectedName ?? placeholder} )} {dropdownArrow && ( -
@@ -209,7 +210,7 @@ const BackgroundButton = (props: ButtonProps) => { {priorityDetails?.title ?? t("common.priority") ?? placeholder} )} {dropdownArrow && ( -