Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d6282d23 | ||
|
|
bec90c2eb9 | ||
|
|
60cab0c964 |
@@ -1,12 +1,12 @@
|
||||
VITE_API_BASE_URL="http://localhost:8000"
|
||||
NEXT_PUBLIC_API_BASE_URL="http://localhost:8000"
|
||||
|
||||
VITE_WEB_BASE_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000"
|
||||
|
||||
VITE_ADMIN_BASE_URL="http://localhost:3001"
|
||||
VITE_ADMIN_BASE_PATH="/god-mode"
|
||||
NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001"
|
||||
NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
|
||||
VITE_SPACE_BASE_URL="http://localhost:3002"
|
||||
VITE_SPACE_BASE_PATH="/spaces"
|
||||
NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002"
|
||||
NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
|
||||
VITE_LIVE_BASE_URL="http://localhost:3100"
|
||||
VITE_LIVE_BASE_PATH="/live"
|
||||
NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100"
|
||||
NEXT_PUBLIC_LIVE_BASE_PATH="/live"
|
||||
|
||||
+25
-25
@@ -28,35 +28,35 @@ FROM base AS installer
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Public envs required at build time (pick up via process.env)
|
||||
ARG VITE_API_BASE_URL=""
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
ARG VITE_API_BASE_PATH="/api"
|
||||
ENV VITE_API_BASE_PATH=$VITE_API_BASE_PATH
|
||||
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 VITE_ADMIN_BASE_URL=""
|
||||
ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL
|
||||
ARG VITE_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_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 VITE_SPACE_BASE_URL=""
|
||||
ENV VITE_SPACE_BASE_URL=$VITE_SPACE_BASE_URL
|
||||
ARG VITE_SPACE_BASE_PATH="/spaces"
|
||||
ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_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 VITE_LIVE_BASE_URL=""
|
||||
ENV VITE_LIVE_BASE_URL=$VITE_LIVE_BASE_URL
|
||||
ARG VITE_LIVE_BASE_PATH="/live"
|
||||
ENV VITE_LIVE_BASE_PATH=$VITE_LIVE_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 VITE_WEB_BASE_URL=""
|
||||
ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
|
||||
ARG VITE_WEB_BASE_PATH=""
|
||||
ENV VITE_WEB_BASE_PATH=$VITE_WEB_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 VITE_WEBSITE_URL="https://plane.so"
|
||||
ENV VITE_WEBSITE_URL=$VITE_WEBSITE_URL
|
||||
ARG VITE_SUPPORT_EMAIL="support@plane.so"
|
||||
ENV VITE_SUPPORT_EMAIL=$VITE_SUPPORT_EMAIL
|
||||
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/ .
|
||||
@@ -66,7 +66,7 @@ COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
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 CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@ COPY . .
|
||||
RUN corepack enable pnpm && pnpm add -g turbo
|
||||
RUN pnpm install
|
||||
|
||||
ENV VITE_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/* eslint-disable import/order */
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.VITE_SENTRY_DSN,
|
||||
environment: process.env.VITE_SENTRY_ENVIRONMENT,
|
||||
sendDefaultPii: process.env.VITE_SENTRY_SEND_DEFAULT_PII ? process.env.VITE_SENTRY_SEND_DEFAULT_PII === "1" : false,
|
||||
release: process.env.VITE_APP_VERSION,
|
||||
tracesSampleRate: process.env.VITE_SENTRY_TRACES_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_TRACES_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
profilesSampleRate: process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
replaysSessionSampleRate: process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
replaysOnErrorSampleRate: process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE)
|
||||
: 1.0,
|
||||
integrations: [],
|
||||
});
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>
|
||||
);
|
||||
});
|
||||
+2
-12
@@ -1,12 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
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 { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import globalStyles from "@/styles/globals.css?url";
|
||||
import type { Route } from "./+types/root";
|
||||
import { AppProviders } from "./providers";
|
||||
@@ -60,18 +58,10 @@ export default function Root() {
|
||||
}
|
||||
|
||||
export function HydrateFallback() {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
if (error) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<div>
|
||||
<p>Something went wrong.</p>
|
||||
|
||||
@@ -38,6 +38,7 @@ const PROGRESS_CONFIG: Readonly<ProgressConfig> = {
|
||||
easing: "ease",
|
||||
trickle: true,
|
||||
delay: 0,
|
||||
isDisabled: import.meta.env.PROD, // Disable progress bar in production builds
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
+14
-4
@@ -6,9 +6,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "react-router dev --port 3001",
|
||||
"dev": "cross-env NODE_ENV=development PORT=3001 node server.mjs",
|
||||
"build": "react-router build",
|
||||
"preview": "react-router build && serve -s build/client -l 3001",
|
||||
"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",
|
||||
@@ -27,16 +27,23 @@
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@react-router/express": "^7.9.3",
|
||||
"@react-router/node": "^7.9.3",
|
||||
"@sentry/react-router": "catalog:",
|
||||
"@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:",
|
||||
"morgan": "^1.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
@@ -52,12 +59,15 @@
|
||||
"@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:",
|
||||
"vite": "catalog:",
|
||||
"vite": "7.1.7",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
|
||||
const basePath = joinUrlPath(process.env.VITE_ADMIN_BASE_PATH ?? "", "/") ?? "/";
|
||||
|
||||
export default {
|
||||
appDirectory: "app",
|
||||
basename: basePath,
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
+50
-27
@@ -4,33 +4,56 @@ import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
|
||||
// Expose only vars starting with VITE_
|
||||
const viteEnv = Object.keys(process.env)
|
||||
.filter((k) => k.startsWith("VITE_"))
|
||||
.reduce<Record<string, string>>((a, k) => {
|
||||
a[k] = process.env[k] ?? "";
|
||||
return a;
|
||||
}, {});
|
||||
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 basePath = joinUrlPath(process.env.VITE_ADMIN_BASE_PATH ?? "", "/") ?? "/";
|
||||
const publicEnv = PUBLIC_ENV_KEYS.reduce<Record<string, string>>((acc, key) => {
|
||||
acc[key] = process.env[key] ?? "";
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export default defineConfig(() => ({
|
||||
base: basePath,
|
||||
define: {
|
||||
"process.env": JSON.stringify(viteEnv),
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
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"),
|
||||
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),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
// No SSR-specific overrides needed; alias resolves to ESM build
|
||||
}));
|
||||
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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
from plane.db.models import (
|
||||
CommentReaction,
|
||||
Cycle,
|
||||
@@ -32,7 +31,7 @@ from plane.settings.redis import redis_instance
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.issue_relation_mapper import get_inverse_relation
|
||||
from plane.utils.uuid import is_valid_uuid
|
||||
|
||||
from plane.bgtasks.notification_task import process_issue_notifications
|
||||
|
||||
def extract_ids(data: dict | None, primary_key: str, fallback_key: str) -> set[str]:
|
||||
if not data:
|
||||
@@ -1580,18 +1579,18 @@ def issue_activity(
|
||||
issue_activities_created = IssueActivity.objects.bulk_create(issue_activities)
|
||||
|
||||
if notification:
|
||||
notifications.delay(
|
||||
type=type,
|
||||
process_issue_notifications(
|
||||
issue_id=issue_id,
|
||||
actor_id=actor_id,
|
||||
project_id=project_id,
|
||||
subscriber=subscriber,
|
||||
issue_activities_created=json.dumps(
|
||||
actor_id=actor_id,
|
||||
activities_data=json.dumps(
|
||||
IssueActivitySerializer(issue_activities_created, many=True).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
requested_data=requested_data,
|
||||
current_instance=current_instance,
|
||||
subscriber=subscriber,
|
||||
notification_type=type,
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
@@ -1,670 +1,59 @@
|
||||
# Python imports
|
||||
import json
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
IssueMention,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
User,
|
||||
IssueAssignee,
|
||||
Issue,
|
||||
State,
|
||||
EmailNotificationLog,
|
||||
Notification,
|
||||
IssueComment,
|
||||
IssueActivity,
|
||||
UserNotificationPreference,
|
||||
ProjectMember,
|
||||
)
|
||||
from django.db.models import Subquery
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
# =========== Issue Description Html Parsing and notification Functions ======================
|
||||
|
||||
|
||||
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
|
||||
aggregated_issue_mentions = []
|
||||
for mention_id in new_mentions:
|
||||
aggregated_issue_mentions.append(
|
||||
IssueMention(
|
||||
mention_id=mention_id,
|
||||
issue=issue,
|
||||
project=project,
|
||||
workspace_id=project.workspace_id,
|
||||
)
|
||||
)
|
||||
|
||||
IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100)
|
||||
IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete()
|
||||
|
||||
|
||||
def get_new_mentions(requested_instance, current_instance):
|
||||
# requested_data is the newer instance of the current issue
|
||||
# current_instance is the older instance of the current issue, saved in the database
|
||||
|
||||
# extract mentions from both the instance of data
|
||||
mentions_older = extract_mentions(current_instance)
|
||||
|
||||
mentions_newer = extract_mentions(requested_instance)
|
||||
|
||||
# Getting Set Difference from mentions_newer
|
||||
new_mentions = [mention for mention in mentions_newer if mention not in mentions_older]
|
||||
|
||||
return new_mentions
|
||||
|
||||
|
||||
# Get Removed Mention
|
||||
def get_removed_mentions(requested_instance, current_instance):
|
||||
# requested_data is the newer instance of the current issue
|
||||
# current_instance is the older instance of the current issue, saved in the database
|
||||
|
||||
# extract mentions from both the instance of data
|
||||
mentions_older = extract_mentions(current_instance)
|
||||
mentions_newer = extract_mentions(requested_instance)
|
||||
|
||||
# Getting Set Difference from mentions_newer
|
||||
removed_mentions = [mention for mention in mentions_older if mention not in mentions_newer]
|
||||
|
||||
return removed_mentions
|
||||
|
||||
|
||||
# Adds mentions as subscribers
|
||||
def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
||||
# mentions is an array of User IDs representing the FILTERED set of mentioned users
|
||||
|
||||
bulk_mention_subscribers = []
|
||||
|
||||
for mention_id in mentions:
|
||||
# If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification # noqa: E501
|
||||
if (
|
||||
not IssueSubscriber.objects.filter(
|
||||
issue_id=issue_id, subscriber_id=mention_id, project_id=project_id
|
||||
).exists()
|
||||
and not IssueAssignee.objects.filter(
|
||||
project_id=project_id, issue_id=issue_id, assignee_id=mention_id
|
||||
).exists()
|
||||
and not Issue.objects.filter(project_id=project_id, pk=issue_id, created_by_id=mention_id).exists()
|
||||
and ProjectMember.objects.filter(project_id=project_id, member_id=mention_id, is_active=True).exists()
|
||||
):
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
bulk_mention_subscribers.append(
|
||||
IssueSubscriber(
|
||||
workspace_id=project.workspace_id,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
subscriber_id=mention_id,
|
||||
)
|
||||
)
|
||||
return bulk_mention_subscribers
|
||||
|
||||
|
||||
# Parse Issue Description & extracts mentions
|
||||
def extract_mentions(issue_instance):
|
||||
try:
|
||||
# issue_instance has to be a dictionary passed, containing the description_html and other set of activity data. # noqa: E501
|
||||
mentions = []
|
||||
# Convert string to dictionary
|
||||
data = json.loads(issue_instance)
|
||||
html = data.get("description_html")
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
mention_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"})
|
||||
|
||||
mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
|
||||
|
||||
return list(set(mentions))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# =========== Comment Parsing and notification Functions ======================
|
||||
def extract_comment_mentions(comment_value):
|
||||
try:
|
||||
mentions = []
|
||||
soup = BeautifulSoup(comment_value, "html.parser")
|
||||
mentions_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"})
|
||||
for mention_tag in mentions_tags:
|
||||
mentions.append(mention_tag["entity_identifier"])
|
||||
return list(set(mentions))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_new_comment_mentions(new_value, old_value):
|
||||
mentions_newer = extract_comment_mentions(new_value)
|
||||
if old_value is None:
|
||||
return mentions_newer
|
||||
|
||||
mentions_older = extract_comment_mentions(old_value)
|
||||
# Getting Set Difference from mentions_newer
|
||||
new_mentions = [mention for mention in mentions_newer if mention not in mentions_older]
|
||||
|
||||
return new_mentions
|
||||
|
||||
|
||||
def create_mention_notification(project, notification_comment, issue, actor_id, mention_id, issue_id, activity):
|
||||
return Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities:mentioned",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
message=notification_comment,
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(activity.get("id")),
|
||||
"verb": str(activity.get("verb")),
|
||||
"field": str(activity.get("field")),
|
||||
"actor": str(activity.get("actor_id")),
|
||||
"new_value": str(activity.get("new_value")),
|
||||
"old_value": str(activity.get("old_value")),
|
||||
"old_identifier": (str(activity.get("old_identifier")) if activity.get("old_identifier") else None),
|
||||
"new_identifier": (str(activity.get("new_identifier")) if activity.get("new_identifier") else None),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from plane.utils.notifications import IssueNotificationHandler, NotificationContext
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
@shared_task
|
||||
def notifications(
|
||||
type,
|
||||
def process_issue_notifications(
|
||||
issue_id,
|
||||
project_id,
|
||||
actor_id,
|
||||
subscriber,
|
||||
issue_activities_created,
|
||||
requested_data,
|
||||
current_instance,
|
||||
activities_data,
|
||||
requested_data=None,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
notification_type=""
|
||||
):
|
||||
"""
|
||||
Process notifications for issue activities.
|
||||
"""
|
||||
try:
|
||||
issue_activities_created = (
|
||||
json.loads(issue_activities_created) if issue_activities_created is not None else None
|
||||
# Let the handler normalize and parse activities
|
||||
activities = IssueNotificationHandler.parse_activities(activities_data)
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
workspace_id = project.workspace_id
|
||||
|
||||
# Create context
|
||||
context = NotificationContext(
|
||||
entity_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
actor_id=actor_id,
|
||||
activities=activities,
|
||||
requested_data=requested_data,
|
||||
current_instance=current_instance,
|
||||
subscriber=subscriber,
|
||||
notification_type=notification_type
|
||||
)
|
||||
if type not in [
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
"module.activity.deleted",
|
||||
"issue_reaction.activity.created",
|
||||
"issue_reaction.activity.deleted",
|
||||
"comment_reaction.activity.created",
|
||||
"comment_reaction.activity.deleted",
|
||||
"issue_vote.activity.created",
|
||||
"issue_vote.activity.deleted",
|
||||
"issue_draft.activity.created",
|
||||
"issue_draft.activity.updated",
|
||||
"issue_draft.activity.deleted",
|
||||
]:
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
bulk_email_logs = []
|
||||
|
||||
"""
|
||||
Mention Tasks
|
||||
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
|
||||
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
|
||||
"""
|
||||
|
||||
# get the list of active project members
|
||||
project_members = ProjectMember.objects.filter(project_id=project_id, is_active=True).values_list(
|
||||
"member_id", flat=True
|
||||
)
|
||||
|
||||
# Get new mentions from the newer instance
|
||||
new_mentions = get_new_mentions(requested_instance=requested_data, current_instance=current_instance)
|
||||
new_mentions = list(set(new_mentions) & {str(member) for member in project_members})
|
||||
removed_mention = get_removed_mentions(requested_instance=requested_data, current_instance=current_instance)
|
||||
|
||||
comment_mentions = []
|
||||
all_comment_mentions = []
|
||||
|
||||
# Get New Subscribers from the mentions of the newer instance
|
||||
requested_mentions = extract_mentions(issue_instance=requested_data)
|
||||
mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id, issue_id=issue_id, mentions=requested_mentions
|
||||
)
|
||||
|
||||
for issue_activity in issue_activities_created:
|
||||
issue_comment = issue_activity.get("issue_comment")
|
||||
issue_comment_new_value = issue_activity.get("new_value")
|
||||
issue_comment_old_value = issue_activity.get("old_value")
|
||||
if issue_comment is not None:
|
||||
# TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well.
|
||||
|
||||
all_comment_mentions = all_comment_mentions + extract_comment_mentions(issue_comment_new_value)
|
||||
|
||||
new_comment_mentions = get_new_comment_mentions(
|
||||
old_value=issue_comment_old_value,
|
||||
new_value=issue_comment_new_value,
|
||||
)
|
||||
comment_mentions = comment_mentions + new_comment_mentions
|
||||
comment_mentions = [
|
||||
mention for mention in comment_mentions if UUID(mention) in set(project_members)
|
||||
]
|
||||
|
||||
comment_mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions
|
||||
)
|
||||
"""
|
||||
We will not send subscription activity notification to the below mentioned user sets
|
||||
- Those who have been newly mentioned in the issue description, we will send mention notification to them.
|
||||
- When the activity is a comment_created and there exist a mention in the comment,
|
||||
then we have to send the "mention_in_comment" notification
|
||||
- When the activity is a comment_updated and there exist a mention change,
|
||||
then also we have to send the "mention_in_comment" notification
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------------
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
subscriber__in=Subquery(project_members),
|
||||
)
|
||||
.exclude(subscriber_id__in=list(new_mentions + comment_mentions + [actor_id]))
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
|
||||
if subscriber:
|
||||
# add the user to issue subscriber
|
||||
try:
|
||||
_ = IssueSubscriber.objects.get_or_create(
|
||||
project_id=project_id, issue_id=issue_id, subscriber_id=actor_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
issue_assignees = IssueAssignee.objects.filter(
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
assignee__in=Subquery(project_members),
|
||||
).values_list("assignee", flat=True)
|
||||
|
||||
issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)})
|
||||
|
||||
for subscriber in issue_subscribers:
|
||||
if issue.created_by_id and issue.created_by_id == subscriber:
|
||||
sender = "in_app:issue_activities:created"
|
||||
elif subscriber in issue_assignees and issue.created_by_id not in issue_assignees:
|
||||
sender = "in_app:issue_activities:assigned"
|
||||
else:
|
||||
sender = "in_app:issue_activities:subscribed"
|
||||
|
||||
preference = UserNotificationPreference.objects.get(user_id=subscriber)
|
||||
|
||||
for issue_activity in issue_activities_created:
|
||||
# If activity done in blocking then blocked by email should not go
|
||||
if issue_activity.get("issue_detail").get("id") != issue_id:
|
||||
continue
|
||||
|
||||
# Do not send notification for description update
|
||||
if issue_activity.get("field") == "description":
|
||||
continue
|
||||
|
||||
# Check if the value should be sent or not
|
||||
send_email = False
|
||||
if issue_activity.get("field") == "state" and preference.state_change:
|
||||
send_email = True
|
||||
elif (
|
||||
issue_activity.get("field") == "state"
|
||||
and preference.issue_completed
|
||||
and State.objects.filter(
|
||||
project_id=project_id,
|
||||
pk=issue_activity.get("new_identifier"),
|
||||
group="completed",
|
||||
).exists()
|
||||
):
|
||||
send_email = True
|
||||
elif issue_activity.get("field") == "comment" and preference.comment:
|
||||
send_email = True
|
||||
elif preference.property_change:
|
||||
send_email = True
|
||||
else:
|
||||
send_email = False
|
||||
|
||||
# If activity is of issue comment fetch the comment
|
||||
issue_comment = (
|
||||
IssueComment.objects.filter(
|
||||
id=issue_activity.get("issue_comment"),
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
).first()
|
||||
if issue_activity.get("issue_comment")
|
||||
else None
|
||||
)
|
||||
|
||||
# Create in app notification
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender=sender,
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
title=issue_activity.get("comment"),
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str(issue_activity.get("field")),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"issue_comment": str(
|
||||
issue_comment.comment_stripped if issue_comment is not None else ""
|
||||
),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
# Create email notification
|
||||
if send_email:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"project_id": str(issue.project.id),
|
||||
"workspace_slug": str(issue.project.workspace.slug),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str(issue_activity.get("field")),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"issue_comment": str(
|
||||
issue_comment.comment_stripped if issue_comment is not None else ""
|
||||
),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------------------------------------- #
|
||||
|
||||
# Add Mentioned as Issue Subscribers
|
||||
IssueSubscriber.objects.bulk_create(
|
||||
mention_subscribers + comment_mention_subscribers,
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
|
||||
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
|
||||
for mention_id in comment_mentions:
|
||||
if mention_id != actor_id:
|
||||
preference = UserNotificationPreference.objects.get(user_id=mention_id)
|
||||
for issue_activity in issue_activities_created:
|
||||
notification = create_mention_notification(
|
||||
project=project,
|
||||
issue=issue,
|
||||
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", # noqa: E501
|
||||
actor_id=actor_id,
|
||||
mention_id=mention_id,
|
||||
issue_id=issue_id,
|
||||
activity=issue_activity,
|
||||
)
|
||||
|
||||
# check for email notifications
|
||||
if preference.mention:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
"project_id": str(issue.project.id),
|
||||
"workspace_slug": str(issue.project.workspace.slug),
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str("mention"),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
bulk_notifications.append(notification)
|
||||
|
||||
for mention_id in new_mentions:
|
||||
if mention_id != actor_id:
|
||||
preference = UserNotificationPreference.objects.get(user_id=mention_id)
|
||||
if (
|
||||
last_activity is not None
|
||||
and last_activity.field == "description"
|
||||
and actor_id == str(last_activity.actor_id)
|
||||
):
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities:mentioned",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
message=f"You have been mentioned in the issue {issue.name}",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
"project_id": str(issue.project.id),
|
||||
"workspace_slug": str(issue.project.workspace.slug),
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(last_activity.id),
|
||||
"verb": str(last_activity.verb),
|
||||
"field": str(last_activity.field),
|
||||
"actor": str(last_activity.actor_id),
|
||||
"new_value": str(last_activity.new_value),
|
||||
"old_value": str(last_activity.old_value),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
if preference.mention:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(last_activity.id),
|
||||
"verb": str(last_activity.verb),
|
||||
"field": "mention",
|
||||
"actor": str(last_activity.actor_id),
|
||||
"new_value": str(last_activity.new_value),
|
||||
"old_value": str(last_activity.old_value),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
"activity_time": str(last_activity.created_at),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
for issue_activity in issue_activities_created:
|
||||
notification = create_mention_notification(
|
||||
project=project,
|
||||
issue=issue,
|
||||
notification_comment=f"You have been mentioned in the issue {issue.name}",
|
||||
actor_id=actor_id,
|
||||
mention_id=mention_id,
|
||||
issue_id=issue_id,
|
||||
activity=issue_activity,
|
||||
)
|
||||
if preference.mention:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str("mention"),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
bulk_notifications.append(notification)
|
||||
|
||||
# save new mentions for the particular issue and remove the mentions that has been deleted from the description # noqa: E501
|
||||
update_mentions_for_issue(
|
||||
issue=issue,
|
||||
project=project,
|
||||
new_mentions=new_mentions,
|
||||
removed_mention=removed_mention,
|
||||
)
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||
EmailNotificationLog.objects.bulk_create(bulk_email_logs, batch_size=100, ignore_conflicts=True)
|
||||
return
|
||||
|
||||
# Process notifications
|
||||
handler = IssueNotificationHandler(context)
|
||||
payload = handler.process()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"in_app_count": len(payload.in_app_notifications),
|
||||
"email_count": len(payload.email_logs),
|
||||
}
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return
|
||||
log_exception(e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
from .base import NotificationContext
|
||||
from .workitem import WorkItemNotificationHandler
|
||||
|
||||
__all__ = [
|
||||
"NotificationContext",
|
||||
"WorkItemNotificationHandler",
|
||||
]
|
||||
@@ -0,0 +1,542 @@
|
||||
# Python imports
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
import json
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Notification, EmailNotificationLog, User, UserNotificationPreference
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActivityData:
|
||||
"""Represents a single activity/event"""
|
||||
|
||||
id: str
|
||||
verb: str
|
||||
field: str
|
||||
actor_id: str
|
||||
new_value: Any
|
||||
old_value: Any
|
||||
new_identifier: Optional[str] = None
|
||||
old_identifier: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
comment: Optional[str] = None
|
||||
entity_comment: Optional[str] = None
|
||||
entity_detail: Optional[Dict] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> "ActivityData":
|
||||
"""Create ActivityData from dictionary"""
|
||||
return cls(
|
||||
id=data.get("id"),
|
||||
verb=data.get("verb"),
|
||||
field=data.get("field"),
|
||||
actor_id=data.get("actor_id"),
|
||||
new_value=data.get("new_value"),
|
||||
old_value=data.get("old_value"),
|
||||
new_identifier=data.get("new_identifier"),
|
||||
old_identifier=data.get("old_identifier"),
|
||||
created_at=data.get("created_at"),
|
||||
comment=data.get("comment"),
|
||||
entity_comment=data.get("entity_comment"),
|
||||
entity_detail=data.get("entity_detail"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationContext:
|
||||
"""Context data for notification processing"""
|
||||
|
||||
entity_id: str
|
||||
project_id: Optional[str]
|
||||
workspace_id: str
|
||||
actor_id: str
|
||||
activities: List[ActivityData]
|
||||
requested_data: Optional[str] = None
|
||||
current_instance: Optional[str] = None
|
||||
subscriber: bool = False
|
||||
notification_type: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationPayload:
|
||||
"""Container for processed notification data"""
|
||||
|
||||
in_app_notifications: List[Notification] = field(default_factory=list)
|
||||
email_logs: List[EmailNotificationLog] = field(default_factory=list)
|
||||
push_notifications: List[Dict[str, Any]] = field(default_factory=list)
|
||||
entity_data: Dict[str, Any] = field(default_factory=dict)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MentionData:
|
||||
"""Data related to mentions in entity"""
|
||||
|
||||
new_mentions: List[str] = field(default_factory=list)
|
||||
removed_mentions: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriberData:
|
||||
"""Data related to entity subscribers"""
|
||||
|
||||
subscribers: List[UUID] = field(default_factory=list)
|
||||
|
||||
|
||||
class BaseNotificationHandler(ABC):
|
||||
"""
|
||||
Abstract base class for entity-specific notification handlers.
|
||||
Each entity type (Issue) should implement this.
|
||||
"""
|
||||
|
||||
# Entity-specific configurations (must be overridden)
|
||||
ENTITY_NAME: str = "entity"
|
||||
ENTITY_MODEL = None
|
||||
SUBSCRIBER_MODEL = None
|
||||
MENTION_MODEL = None
|
||||
ACTIVITY_MODEL = None
|
||||
|
||||
@classmethod
|
||||
def normalize_activity_data(cls, activity_dict: Dict) -> Dict:
|
||||
"""
|
||||
Normalize entity-specific activity keys to generic keys.
|
||||
Subclasses should override this to map their specific keys.
|
||||
|
||||
Default implementation does nothing (assumes already normalized).
|
||||
"""
|
||||
return activity_dict
|
||||
|
||||
@classmethod
|
||||
def parse_activities(cls, activities_data: str) -> List[ActivityData]:
|
||||
"""
|
||||
Parse and normalize activities from JSON string.
|
||||
Uses the subclass's normalize_activity_data method.
|
||||
"""
|
||||
parsed_activities = json.loads(activities_data)
|
||||
normalized = [cls.normalize_activity_data(a) for a in parsed_activities]
|
||||
return [ActivityData.from_dict(a) for a in normalized]
|
||||
|
||||
# Notification filters (can be overridden)
|
||||
EXCLUDED_ACTIVITY_TYPES: List[str] = []
|
||||
|
||||
def __init__(self, context: NotificationContext):
|
||||
"""Initialize the notification handler"""
|
||||
self.context = context
|
||||
self.entity = None
|
||||
self.project = None
|
||||
self.workspace = None
|
||||
self.active_members = []
|
||||
self.actor = None
|
||||
self.payload = NotificationPayload()
|
||||
|
||||
def process(self) -> NotificationPayload:
|
||||
"""
|
||||
Main entry point to process notifications for an entity.
|
||||
Orchestrates the entire notification flow.
|
||||
"""
|
||||
# 1. Load entity and related data
|
||||
self.load_entity()
|
||||
self.load_project()
|
||||
self.load_workspace()
|
||||
self.load_actor()
|
||||
self.active_members = self.get_active_members()
|
||||
|
||||
# 2. Filter activities that should trigger notifications
|
||||
if self.should_skip_notification():
|
||||
return self.payload
|
||||
|
||||
# 3. Process mentions - subclasses provide the specific field values
|
||||
mention_results = self.process_entity_mentions()
|
||||
|
||||
# 4. Create subscribers from mentions
|
||||
all_mention_subscribers = []
|
||||
for mention_type, mention_data in mention_results.items():
|
||||
subscribers = self.create_subscribers(mention_data.get("all_mentions", []))
|
||||
all_mention_subscribers.extend(subscribers)
|
||||
mention_data["subscribers"] = subscribers
|
||||
|
||||
# 5. Bulk create all mention subscribers
|
||||
if all_mention_subscribers and self.SUBSCRIBER_MODEL:
|
||||
self.SUBSCRIBER_MODEL.objects.bulk_create(all_mention_subscribers, batch_size=100, ignore_conflicts=True)
|
||||
|
||||
# 6. Update mention records (for description mentions, not comments)
|
||||
self.update_all_mentions(mention_results)
|
||||
|
||||
# 7. Handle subscriber opt-in if requested
|
||||
if self.context.subscriber:
|
||||
self.add_actor_as_subscriber()
|
||||
|
||||
# 8. Get all subscribers (excluding mentioned users to avoid duplicate notifications)
|
||||
all_new_mentions = []
|
||||
for mention_data in mention_results.values():
|
||||
all_new_mentions.extend(mention_data.get("new_mentions", []))
|
||||
|
||||
excluded_users = all_new_mentions + [self.context.actor_id]
|
||||
subscriber_data = self.get_subscribers(excluded_users)
|
||||
|
||||
# 9. Create notifications for subscribers
|
||||
self.create_subscriber_notifications(subscriber_data)
|
||||
|
||||
# 10. Create mention notifications
|
||||
self.create_all_mention_notifications(mention_results)
|
||||
|
||||
# 11. Persist all notifications
|
||||
self.save_notifications()
|
||||
|
||||
return self.payload
|
||||
|
||||
# ==================== Abstract Methods (Must Implement) ====================
|
||||
|
||||
@abstractmethod
|
||||
def load_entity(self):
|
||||
"""Load the main entity (issue, initiative, teamspace, etc.)"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_entity_display_name(self) -> str:
|
||||
"""Get display name for the entity"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_active_members(self) -> List[UUID]:
|
||||
"""Get list of active members who can receive notifications"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_subscribers(self, exclude_users: List[str]) -> SubscriberData:
|
||||
"""Get subscribers for this entity"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_subscribers(self, mentions: List[str]) -> List:
|
||||
"""Create new subscribers for mentions"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def process_entity_mentions(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Process mentions for entity-specific fields.
|
||||
Subclasses call self.process_mentions() with appropriate field values.
|
||||
|
||||
Returns:
|
||||
Dict mapping mention types to their data:
|
||||
{
|
||||
'description': {
|
||||
'new_mentions': [...],
|
||||
'removed_mentions': [...],
|
||||
'all_mentions': [...]
|
||||
},
|
||||
'comment': {
|
||||
'new_mentions': [...],
|
||||
'all_mentions': [...]
|
||||
}
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_all_mentions(self, mention_results: Dict[str, Dict[str, Any]]):
|
||||
"""Update mention records for this entity (usually only description mentions)"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def should_send_email(self, preference: UserNotificationPreference, activity: ActivityData) -> bool:
|
||||
"""Determine if email should be sent for this activity"""
|
||||
pass
|
||||
|
||||
# ==================== Entity Loading Methods ====================
|
||||
|
||||
def load_project(self):
|
||||
"""Load project (can be overridden for project-less entities)"""
|
||||
from plane.db.models import Project
|
||||
|
||||
if self.context.project_id:
|
||||
self.project = Project.objects.get(pk=self.context.project_id)
|
||||
|
||||
def load_workspace(self):
|
||||
"""Load workspace"""
|
||||
from plane.db.models import Workspace
|
||||
|
||||
self.workspace = self.project.workspace if self.project else None
|
||||
if not self.workspace:
|
||||
from plane.db.models import Workspace
|
||||
|
||||
self.workspace = Workspace.objects.get(pk=self.context.workspace_id)
|
||||
|
||||
def load_actor(self):
|
||||
"""Load the user who triggered the activity"""
|
||||
self.actor = User.objects.get(pk=self.context.actor_id)
|
||||
|
||||
# ==================== Mention Processing Methods ====================
|
||||
|
||||
def extract_mentions(self, content: str) -> List[str]:
|
||||
"""
|
||||
Extract mentions from HTML content.
|
||||
"""
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Extract mentions from HTML
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
mention_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"})
|
||||
|
||||
mentions = [
|
||||
mention_tag["entity_identifier"] for mention_tag in mention_tags if mention_tag.get("entity_identifier")
|
||||
]
|
||||
|
||||
return list(set(mentions))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def process_mentions(
|
||||
self, old_value: Optional[str] = None, new_value: Optional[str] = None, filter_to_active: bool = True
|
||||
) -> MentionData:
|
||||
"""
|
||||
Generic mention processing that works for any field.
|
||||
Subclasses call this method with their specific field values.
|
||||
|
||||
Args:
|
||||
old_value: Old content to extract mentions from
|
||||
new_value: New content to extract mentions from
|
||||
filter_to_active: Whether to filter mentions to active members only
|
||||
|
||||
Returns:
|
||||
MentionData with new and removed mentions
|
||||
"""
|
||||
if not old_value and not new_value:
|
||||
return MentionData()
|
||||
|
||||
# Extract mentions from both versions
|
||||
mentions_older = self.extract_mentions(old_value) if old_value else []
|
||||
mentions_newer = self.extract_mentions(new_value) if new_value else []
|
||||
|
||||
# Calculate differences
|
||||
new_mentions = [m for m in mentions_newer if m not in mentions_older]
|
||||
removed_mentions = [m for m in mentions_older if m not in mentions_newer]
|
||||
|
||||
# Filter to active members if requested
|
||||
if filter_to_active and self.active_members:
|
||||
active_member_ids = {str(m) for m in self.active_members}
|
||||
new_mentions = [m for m in new_mentions if m in active_member_ids]
|
||||
|
||||
return MentionData(
|
||||
new_mentions=new_mentions,
|
||||
removed_mentions=removed_mentions,
|
||||
)
|
||||
|
||||
# ==================== Notification Creation Methods ====================
|
||||
|
||||
def create_subscriber_notifications(self, subscriber_data: SubscriberData):
|
||||
"""Create notifications for all subscribers"""
|
||||
for subscriber_id in subscriber_data.subscribers:
|
||||
# Get user preferences
|
||||
try:
|
||||
preference = UserNotificationPreference.objects.get(user_id=subscriber_id)
|
||||
except UserNotificationPreference.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Determine sender type based on relationship
|
||||
sender = self.get_sender_type(subscriber_id, subscriber_data)
|
||||
|
||||
# Create notifications for each activity
|
||||
for activity in self.context.activities:
|
||||
# Skip if activity is for a different entity
|
||||
if not self.is_activity_for_this_entity(activity):
|
||||
continue
|
||||
|
||||
# Skip description updates
|
||||
if activity.field == "description":
|
||||
continue
|
||||
|
||||
# Create in-app notification
|
||||
self.create_in_app_notification(
|
||||
receiver_id=subscriber_id, activity=activity, sender=sender, message=activity.comment
|
||||
)
|
||||
|
||||
# Create email notification if preference allows
|
||||
if self.should_send_email(preference, activity):
|
||||
self.create_email_notification(receiver_id=subscriber_id, activity=activity)
|
||||
|
||||
def create_all_mention_notifications(self, mention_results: Dict[str, Dict[str, Any]]):
|
||||
"""Create notifications for all mention types"""
|
||||
for mention_type, data in mention_results.items():
|
||||
new_mentions = data.get("new_mentions", [])
|
||||
notification_type = data.get("notification_type", mention_type)
|
||||
|
||||
for mention_id in new_mentions:
|
||||
# Skip self-mentions
|
||||
if mention_id == self.context.actor_id:
|
||||
continue
|
||||
|
||||
# Get user preferences
|
||||
try:
|
||||
preference = UserNotificationPreference.objects.get(user_id=mention_id)
|
||||
except UserNotificationPreference.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Create notification message
|
||||
entity_name = self.get_entity_display_name()
|
||||
if notification_type == "comment":
|
||||
message = (
|
||||
f"{self.actor.display_name} has mentioned you in a comment in {self.ENTITY_NAME} {entity_name}"
|
||||
)
|
||||
else:
|
||||
message = f"You have been mentioned in the {self.ENTITY_NAME} {entity_name}"
|
||||
|
||||
# Create notifications for each activity
|
||||
for activity in self.context.activities:
|
||||
# Create in-app notification
|
||||
self.create_in_app_notification(
|
||||
receiver_id=mention_id,
|
||||
activity=activity,
|
||||
sender=f"in_app:{self.ENTITY_NAME}_activities:mentioned",
|
||||
message=message,
|
||||
)
|
||||
|
||||
# Create email notification if preference allows
|
||||
if preference.mention:
|
||||
self.create_email_notification(
|
||||
receiver_id=mention_id, activity=activity, field_override="mention"
|
||||
)
|
||||
|
||||
def create_in_app_notification(
|
||||
self, receiver_id: str, activity: ActivityData, sender: str, message: Optional[str] = None
|
||||
):
|
||||
"""Create an in-app notification"""
|
||||
notification = Notification(
|
||||
workspace=self.workspace,
|
||||
sender=sender,
|
||||
triggered_by_id=self.context.actor_id,
|
||||
receiver_id=receiver_id,
|
||||
entity_identifier=self.context.entity_id,
|
||||
entity_name=self.get_entity_type(),
|
||||
project=self.project,
|
||||
title=message or activity.comment,
|
||||
data=self.build_notification_data(activity),
|
||||
)
|
||||
self.payload.in_app_notifications.append(notification)
|
||||
|
||||
def create_email_notification(self, receiver_id: str, activity: ActivityData, field_override: Optional[str] = None):
|
||||
"""Create an email notification log"""
|
||||
email_log = EmailNotificationLog(
|
||||
triggered_by_id=self.context.actor_id,
|
||||
receiver_id=receiver_id,
|
||||
entity_identifier=self.context.entity_id,
|
||||
entity_name=self.get_entity_type(),
|
||||
data=self.build_email_data(activity, field_override),
|
||||
)
|
||||
self.payload.email_logs.append(email_log)
|
||||
|
||||
# ==================== Notification Data Builders ====================
|
||||
|
||||
def build_notification_data(self, activity: ActivityData) -> Dict[str, Any]:
|
||||
"""Build notification data dictionary (can be overridden for entity-specific data)"""
|
||||
return {
|
||||
self.ENTITY_NAME: self.get_entity_data(),
|
||||
f"{self.ENTITY_NAME}_activity": {
|
||||
"id": str(activity.id),
|
||||
"verb": str(activity.verb),
|
||||
"field": str(activity.field),
|
||||
"actor": str(activity.actor_id),
|
||||
"new_value": str(activity.new_value),
|
||||
"old_value": str(activity.old_value),
|
||||
"old_identifier": str(activity.old_identifier) if activity.old_identifier else None,
|
||||
"new_identifier": str(activity.new_identifier) if activity.new_identifier else None,
|
||||
},
|
||||
}
|
||||
|
||||
def build_email_data(self, activity: ActivityData, field_override: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Build email notification data dictionary (can be overridden)"""
|
||||
data = self.build_notification_data(activity)
|
||||
|
||||
# Add email-specific fields
|
||||
activity_key = f"{self.ENTITY_NAME}_activity"
|
||||
if activity_key in data:
|
||||
data[activity_key]["activity_time"] = activity.created_at
|
||||
|
||||
# Override field if provided (e.g., for mention notifications)
|
||||
if field_override:
|
||||
data[activity_key]["field"] = field_override
|
||||
|
||||
return data
|
||||
|
||||
# ==================== Helper Methods ====================
|
||||
def get_sender_type(self, subscriber_id: UUID, subscriber_data: SubscriberData) -> str:
|
||||
"""Determine sender type based on subscriber relationship"""
|
||||
# Check if subscriber is the creator
|
||||
if self.entity and hasattr(self.entity, "created_by_id"):
|
||||
if self.entity.created_by_id == subscriber_id:
|
||||
return f"in_app:{self.ENTITY_NAME}_activities:created"
|
||||
|
||||
# Check if subscriber is an assignee (if entity supports assignees)
|
||||
if self.is_assignee(subscriber_id):
|
||||
# Only use "assigned" if creator is not also an assignee
|
||||
if not (
|
||||
self.entity and hasattr(self.entity, "created_by_id") and self.entity.created_by_id == subscriber_id
|
||||
):
|
||||
return f"in_app:{self.ENTITY_NAME}_activities:assigned"
|
||||
|
||||
return f"in_app:{self.ENTITY_NAME}_activities:subscribed"
|
||||
|
||||
def is_assignee(self, user_id: UUID) -> bool:
|
||||
"""Check if user is an assignee (can be overridden for entities without assignees)"""
|
||||
return False # Default: no assignees
|
||||
|
||||
def get_entity_type(self) -> str:
|
||||
"""Get entity type string (can be overridden for special cases like epics)"""
|
||||
return self.ENTITY_NAME
|
||||
|
||||
def should_skip_notification(self) -> bool:
|
||||
"""Check if notification should be skipped for this activity type"""
|
||||
return self.context.notification_type in self.EXCLUDED_ACTIVITY_TYPES
|
||||
|
||||
def should_send_push_notifications(self) -> bool:
|
||||
"""Determine if push notifications should be sent (can be overridden)"""
|
||||
return True
|
||||
|
||||
def is_activity_for_this_entity(self, activity: ActivityData) -> bool:
|
||||
"""Check if activity is for this entity (for blocking/blocked_by scenarios)"""
|
||||
if activity.entity_detail:
|
||||
return activity.entity_detail.get("id") == self.context.entity_id
|
||||
return True
|
||||
|
||||
def add_actor_as_subscriber(self):
|
||||
"""Add the actor as a subscriber if requested"""
|
||||
if not self.SUBSCRIBER_MODEL:
|
||||
return
|
||||
|
||||
try:
|
||||
subscriber_data = {"subscriber_id": self.context.actor_id}
|
||||
|
||||
if self.project:
|
||||
subscriber_data["project_id"] = self.context.project_id
|
||||
|
||||
subscriber_data["workspace_id"] = self.context.workspace_id
|
||||
subscriber_data[f"{self.ENTITY_NAME}_id"] = self.context.entity_id
|
||||
|
||||
self.SUBSCRIBER_MODEL.objects.get_or_create(**subscriber_data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ==================== Persistence Methods ====================
|
||||
|
||||
def save_notifications(self):
|
||||
"""Persist all notifications to the database"""
|
||||
# Bulk create in-app notifications
|
||||
if self.payload.in_app_notifications:
|
||||
notifications = Notification.objects.bulk_create(self.payload.in_app_notifications, batch_size=100)
|
||||
# Update payload with saved notifications (with IDs)
|
||||
self.payload.in_app_notifications = notifications
|
||||
|
||||
# Bulk create email logs
|
||||
if self.payload.email_logs:
|
||||
EmailNotificationLog.objects.bulk_create(self.payload.email_logs, batch_size=100, ignore_conflicts=True)
|
||||
|
||||
# ==================== External Notification send methods ====================
|
||||
def send_email_notification(self, template_name: str, context: Dict[str, Any], receiver_data: Dict[str, Any]):
|
||||
"""Send an email notification"""
|
||||
pass
|
||||
@@ -0,0 +1,404 @@
|
||||
# Python imports
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Subquery
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueSubscriber,
|
||||
IssueMention,
|
||||
IssueAssignee,
|
||||
IssueComment,
|
||||
IssueActivity,
|
||||
ProjectMember,
|
||||
State,
|
||||
UserNotificationPreference,
|
||||
)
|
||||
from plane.utils.notifications.base import (
|
||||
BaseNotificationHandler,
|
||||
SubscriberData,
|
||||
ActivityData,
|
||||
)
|
||||
|
||||
|
||||
class WorkItemNotificationHandler(BaseNotificationHandler):
|
||||
"""
|
||||
Notification handler for Issues and Epics.
|
||||
Handles all issue-related notifications including mentions, assignments, and property changes.
|
||||
"""
|
||||
|
||||
# Entity configuration
|
||||
ENTITY_NAME = "issue"
|
||||
ENTITY_MODEL = Issue
|
||||
SUBSCRIBER_MODEL = IssueSubscriber
|
||||
MENTION_MODEL = IssueMention
|
||||
ACTIVITY_MODEL = IssueActivity
|
||||
|
||||
# Activity types that should not trigger notifications
|
||||
EXCLUDED_ACTIVITY_TYPES = [
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
"module.activity.deleted",
|
||||
"issue_reaction.activity.created",
|
||||
"issue_reaction.activity.deleted",
|
||||
"comment_reaction.activity.created",
|
||||
"comment_reaction.activity.deleted",
|
||||
"issue_vote.activity.created",
|
||||
"issue_vote.activity.deleted",
|
||||
"issue_draft.activity.created",
|
||||
"issue_draft.activity.updated",
|
||||
"issue_draft.activity.deleted",
|
||||
]
|
||||
|
||||
# Cleaning methods
|
||||
@classmethod
|
||||
def normalize_activity_data(cls, activity_dict: Dict) -> Dict:
|
||||
"""
|
||||
Normalize issue-specific activity keys to generic keys.
|
||||
Maps issue_comment -> entity_comment, issue_detail -> entity_detail
|
||||
"""
|
||||
normalized = activity_dict.copy()
|
||||
|
||||
# Map issue-specific keys to generic keys
|
||||
if 'issue_comment' in normalized:
|
||||
normalized['entity_comment'] = normalized.pop('issue_comment')
|
||||
if 'issue_detail' in normalized:
|
||||
normalized['entity_detail'] = normalized.pop('issue_detail')
|
||||
|
||||
return normalized
|
||||
|
||||
# ==================== Entity Loading ====================
|
||||
|
||||
def load_entity(self):
|
||||
"""Load the issue"""
|
||||
self.entity = Issue.objects.filter(pk=self.context.entity_id).first()
|
||||
|
||||
def get_entity_display_name(self) -> str:
|
||||
"""Get display name for the issue"""
|
||||
return self.entity.name if self.entity else ""
|
||||
|
||||
def get_entity_data(self) -> Dict[str, Any]:
|
||||
"""Get issue data for notification payload"""
|
||||
if not self.entity:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"id": str(self.context.entity_id),
|
||||
"name": str(self.entity.name),
|
||||
"identifier": str(self.entity.project.identifier),
|
||||
"sequence_id": self.entity.sequence_id,
|
||||
"state_name": self.entity.state.name,
|
||||
"state_group": self.entity.state.group,
|
||||
"type_id": str(self.entity.type_id),
|
||||
}
|
||||
|
||||
def get_entity_type(self) -> str:
|
||||
"""Return 'epic' if issue is an epic, otherwise 'issue'"""
|
||||
if self.entity and self.entity.type and self.entity.type.is_epic:
|
||||
return "epic"
|
||||
return "issue"
|
||||
|
||||
# ==================== Member & Subscriber Management ====================
|
||||
|
||||
def get_active_members(self) -> List[UUID]:
|
||||
"""Get list of active project members"""
|
||||
if not self.context.project_id:
|
||||
return []
|
||||
|
||||
return list(
|
||||
ProjectMember.objects.filter(
|
||||
project_id=self.context.project_id,
|
||||
is_active=True
|
||||
).values_list("member_id", flat=True)
|
||||
)
|
||||
|
||||
def get_subscribers(self, exclude_users: List[str]) -> SubscriberData:
|
||||
"""Get issue subscribers, assignees, and creator"""
|
||||
# Get subscribers (excluding mentioned users and actor)
|
||||
subscribers = list(
|
||||
IssueSubscriber.objects.filter(
|
||||
project_id=self.context.project_id,
|
||||
issue_id=self.context.entity_id,
|
||||
subscriber__in=Subquery(
|
||||
ProjectMember.objects.filter(
|
||||
project_id=self.context.project_id,
|
||||
is_active=True
|
||||
).values("member_id")
|
||||
)
|
||||
)
|
||||
.exclude(subscriber_id__in=exclude_users)
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
|
||||
# Get assignees
|
||||
assignees = list(
|
||||
IssueAssignee.objects.filter(
|
||||
issue_id=self.context.entity_id,
|
||||
project_id=self.context.project_id,
|
||||
assignee__in=Subquery(
|
||||
ProjectMember.objects.filter(
|
||||
project_id=self.context.project_id,
|
||||
is_active=True
|
||||
).values("member_id")
|
||||
)
|
||||
).values_list("assignee", flat=True)
|
||||
)
|
||||
|
||||
subscribers.extend(assignees)
|
||||
if self.entity and self.entity.created_by_id:
|
||||
subscribers.append(self.entity.created_by_id)
|
||||
|
||||
# Remove duplicates and convert to UUIDs
|
||||
subscribers = list(set(subscribers))
|
||||
|
||||
# Remove actor from the list
|
||||
subscribers = [subscriber for subscriber in subscribers if subscriber != UUID(self.context.actor_id)]
|
||||
|
||||
return SubscriberData(
|
||||
subscribers=subscribers,
|
||||
)
|
||||
|
||||
def create_subscribers(self, mentions: List[str]) -> List[IssueSubscriber]:
|
||||
"""
|
||||
Create issue subscribers for mentioned users.
|
||||
Only creates if they're not already a subscriber, assignee, or creator.
|
||||
"""
|
||||
if not mentions:
|
||||
return []
|
||||
|
||||
bulk_subscribers = []
|
||||
|
||||
for mention_id in mentions:
|
||||
# Only create subscriber if they're not already involved with the issue
|
||||
if (
|
||||
not IssueSubscriber.objects.filter(
|
||||
issue_id=self.context.entity_id,
|
||||
subscriber_id=mention_id,
|
||||
project_id=self.context.project_id
|
||||
).exists()
|
||||
and not IssueAssignee.objects.filter(
|
||||
project_id=self.context.project_id,
|
||||
issue_id=self.context.entity_id,
|
||||
assignee_id=mention_id
|
||||
).exists()
|
||||
and not Issue.objects.filter(
|
||||
project_id=self.context.project_id,
|
||||
pk=self.context.entity_id,
|
||||
created_by_id=mention_id
|
||||
).exists()
|
||||
and ProjectMember.objects.filter(
|
||||
project_id=self.context.project_id,
|
||||
member_id=mention_id,
|
||||
is_active=True
|
||||
).exists()
|
||||
):
|
||||
bulk_subscribers.append(
|
||||
IssueSubscriber(
|
||||
workspace_id=self.project.workspace_id,
|
||||
project_id=self.context.project_id,
|
||||
issue_id=self.context.entity_id,
|
||||
subscriber_id=mention_id,
|
||||
)
|
||||
)
|
||||
|
||||
return bulk_subscribers
|
||||
|
||||
# ==================== Mention Processing ====================
|
||||
|
||||
def process_entity_mentions(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Process mentions for issue-specific fields (description and comments).
|
||||
Calls base class process_mentions() with field values.
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# 1. Process description mentions (JSON format)
|
||||
if self.context.current_instance and self.context.requested_data:
|
||||
|
||||
current_instance = json.loads(self.context.current_instance)
|
||||
requested_data = json.loads(self.context.requested_data)
|
||||
|
||||
description_mentions = self.process_mentions(
|
||||
old_value=current_instance.get("description_html"),
|
||||
new_value=requested_data.get("description_html"),
|
||||
filter_to_active=True
|
||||
)
|
||||
|
||||
# Get all current mentions for subscriber creation
|
||||
current_mentions = self.extract_mentions(current_instance.get("description_html"))
|
||||
|
||||
results['description'] = {
|
||||
'new_mentions': description_mentions.new_mentions,
|
||||
'removed_mentions': description_mentions.removed_mentions,
|
||||
'all_mentions': current_mentions,
|
||||
'notification_type': 'description'
|
||||
}
|
||||
|
||||
# 2. Process comment mentions (HTML format)
|
||||
comment_mentions_new = []
|
||||
comment_mentions_all = []
|
||||
|
||||
for activity in self.context.activities:
|
||||
# Check if this activity has a comment
|
||||
if activity.entity_comment is not None:
|
||||
# Process mentions for this specific comment
|
||||
mention_data = self.process_mentions(
|
||||
old_value=activity.old_value,
|
||||
new_value=activity.new_value,
|
||||
filter_to_active=True
|
||||
)
|
||||
|
||||
# Collect new mentions from this comment
|
||||
comment_mentions_new.extend(mention_data.new_mentions)
|
||||
|
||||
# Get all mentions from new value
|
||||
if activity.new_value:
|
||||
all_mentions = self.extract_mentions(activity.new_value)
|
||||
comment_mentions_all.extend(all_mentions)
|
||||
|
||||
if comment_mentions_new or comment_mentions_all:
|
||||
results['comment'] = {
|
||||
'new_mentions': comment_mentions_new,
|
||||
'all_mentions': comment_mentions_all,
|
||||
'notification_type': 'comment'
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
def update_all_mentions(self, mention_results: Dict[str, Dict[str, Any]]):
|
||||
"""
|
||||
Update issue mention records.
|
||||
Only updates description mentions (comment mentions are not stored).
|
||||
"""
|
||||
# Only update description mentions
|
||||
if 'description' not in mention_results:
|
||||
return
|
||||
|
||||
data = mention_results['description']
|
||||
|
||||
# Create new mentions
|
||||
bulk_mentions = []
|
||||
for mention_id in data.get('new_mentions', []):
|
||||
bulk_mentions.append(
|
||||
IssueMention(
|
||||
mention_id=mention_id,
|
||||
issue_id=self.context.entity_id,
|
||||
project=self.project,
|
||||
workspace_id=self.project.workspace_id,
|
||||
)
|
||||
)
|
||||
|
||||
if bulk_mentions:
|
||||
IssueMention.objects.bulk_create(bulk_mentions, batch_size=100)
|
||||
|
||||
# Delete removed mentions
|
||||
removed_mentions = data.get('removed_mentions', [])
|
||||
if removed_mentions:
|
||||
IssueMention.objects.filter(
|
||||
issue_id=self.context.entity_id,
|
||||
mention__in=removed_mentions
|
||||
).delete()
|
||||
|
||||
# ==================== Email Preferences ====================
|
||||
|
||||
def should_send_email(
|
||||
self,
|
||||
preference: UserNotificationPreference,
|
||||
activity: ActivityData
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if email should be sent based on user preferences and activity type.
|
||||
Implements issue-specific email preference logic.
|
||||
"""
|
||||
# State changes
|
||||
if activity.field == "state":
|
||||
# Check if state change emails are enabled
|
||||
if preference.state_change:
|
||||
return True
|
||||
# Check if issue completion emails are enabled and issue was completed
|
||||
if (
|
||||
preference.issue_completed
|
||||
and activity.new_identifier
|
||||
and State.objects.filter(
|
||||
project_id=self.context.project_id,
|
||||
pk=activity.new_identifier,
|
||||
group="completed"
|
||||
).exists()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
# Comments
|
||||
elif activity.field == "comment":
|
||||
return preference.comment
|
||||
|
||||
# Page links
|
||||
elif activity.field == "page":
|
||||
return True
|
||||
|
||||
# All other property changes
|
||||
elif preference.property_change:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# ==================== Notification Data Building ====================
|
||||
|
||||
def build_notification_data(self, activity: ActivityData) -> Dict[str, Any]:
|
||||
"""Build notification data with issue comment if available"""
|
||||
data = super().build_notification_data(activity)
|
||||
|
||||
# Add comment content if available
|
||||
if activity.entity_comment:
|
||||
issue_comment = IssueComment.objects.filter(
|
||||
id=activity.entity_comment,
|
||||
issue_id=self.context.entity_id,
|
||||
project_id=self.context.project_id,
|
||||
workspace_id=self.workspace.id,
|
||||
).first()
|
||||
|
||||
if issue_comment:
|
||||
data["issue_activity"]["issue_comment"] = str(
|
||||
issue_comment.comment_stripped
|
||||
)
|
||||
else:
|
||||
data["issue_activity"]["issue_comment"] = ""
|
||||
|
||||
return data
|
||||
|
||||
def build_email_data(
|
||||
self,
|
||||
activity: ActivityData,
|
||||
field_override: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Build email data with additional project and workspace info"""
|
||||
data = super().build_email_data(activity, field_override)
|
||||
|
||||
# Add email-specific fields for issues
|
||||
if "issue" in data and self.project:
|
||||
data["issue"]["project_id"] = str(self.project.id)
|
||||
data["issue"]["workspace_slug"] = str(self.project.workspace.slug)
|
||||
|
||||
# Add comment content if available
|
||||
if activity.entity_comment:
|
||||
issue_comment = IssueComment.objects.filter(
|
||||
id=activity.entity_comment,
|
||||
issue_id=self.context.entity_id,
|
||||
project_id=self.context.project_id,
|
||||
workspace_id=self.workspace.id,
|
||||
).first()
|
||||
|
||||
if issue_comment:
|
||||
data["issue_activity"]["issue_comment"] = str(
|
||||
issue_comment.comment_stripped
|
||||
)
|
||||
else:
|
||||
data["issue_activity"]["issue_comment"] = ""
|
||||
|
||||
return data
|
||||
|
||||
@@ -39,7 +39,7 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/
|
||||
# Build the project and its dependencies
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
VITE_API_BASE_URL="http://localhost:8000"
|
||||
NEXT_PUBLIC_API_BASE_URL="http://localhost:8000"
|
||||
|
||||
VITE_WEB_BASE_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000"
|
||||
|
||||
VITE_ADMIN_BASE_URL="http://localhost:3001"
|
||||
VITE_ADMIN_BASE_PATH="/god-mode"
|
||||
NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001"
|
||||
NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
|
||||
VITE_SPACE_BASE_URL="http://localhost:3002"
|
||||
VITE_SPACE_BASE_PATH="/spaces"
|
||||
NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002"
|
||||
NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
|
||||
VITE_LIVE_BASE_URL="http://localhost:3100"
|
||||
VITE_LIVE_BASE_PATH="/live"
|
||||
NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100"
|
||||
NEXT_PUBLIC_LIVE_BASE_PATH="/live"
|
||||
|
||||
@@ -12,7 +12,7 @@ RUN pnpm install
|
||||
|
||||
EXPOSE 3002
|
||||
|
||||
ENV VITE_SPACE_BASE_PATH="/spaces"
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
|
||||
VOLUME [ "/app/node_modules", "/app/apps/space/node_modules"]
|
||||
|
||||
|
||||
+29
-33
@@ -28,36 +28,35 @@ FROM base AS installer
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Public envs required at build time (pick up via process.env)
|
||||
ARG VITE_API_BASE_URL=""
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
ARG VITE_API_BASE_PATH="/api"
|
||||
ENV VITE_API_BASE_PATH=$VITE_API_BASE_PATH
|
||||
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 VITE_ADMIN_BASE_URL=""
|
||||
ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL
|
||||
ARG VITE_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_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 VITE_SPACE_BASE_URL=""
|
||||
ENV VITE_SPACE_BASE_URL=$VITE_SPACE_BASE_URL
|
||||
ARG VITE_SPACE_BASE_PATH="/spaces"
|
||||
ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_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 VITE_LIVE_BASE_URL=""
|
||||
ENV VITE_LIVE_BASE_URL=$VITE_LIVE_BASE_URL
|
||||
ARG VITE_LIVE_BASE_PATH="/live"
|
||||
ENV VITE_LIVE_BASE_PATH=$VITE_LIVE_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 VITE_WEB_BASE_URL=""
|
||||
ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
|
||||
ARG VITE_WEB_BASE_PATH=""
|
||||
ENV VITE_WEB_BASE_PATH=$VITE_WEB_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 VITE_WEBSITE_URL="https://plane.so"
|
||||
ENV VITE_WEBSITE_URL=$VITE_WEBSITE_URL
|
||||
|
||||
ARG VITE_SUPPORT_EMAIL="support@plane.so"
|
||||
ENV VITE_SUPPORT_EMAIL=$VITE_SUPPORT_EMAIL
|
||||
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/ .
|
||||
@@ -67,24 +66,21 @@ COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
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 CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
|
||||
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
|
||||
|
||||
# =========================================================================== #
|
||||
|
||||
FROM base AS runner
|
||||
FROM nginx:1.27-alpine AS production
|
||||
|
||||
COPY --from=installer /app/apps/space/build ./apps/space/build
|
||||
COPY --from=installer /app/apps/space/node_modules ./apps/space/node_modules
|
||||
COPY --from=installer /app/node_modules ./node_modules
|
||||
|
||||
WORKDIR /app/apps/space
|
||||
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
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1
|
||||
|
||||
CMD ["npx", "react-router-serve", "./build/server/index.js"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/* eslint-disable import/order */
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.VITE_SENTRY_DSN,
|
||||
environment: process.env.VITE_SENTRY_ENVIRONMENT,
|
||||
sendDefaultPii: process.env.VITE_SENTRY_SEND_DEFAULT_PII ? process.env.VITE_SENTRY_SEND_DEFAULT_PII === "1" : false,
|
||||
release: process.env.VITE_APP_VERSION,
|
||||
tracesSampleRate: process.env.VITE_SENTRY_TRACES_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_TRACES_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
profilesSampleRate: process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
replaysSessionSampleRate: process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
replaysOnErrorSampleRate: process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE)
|
||||
: 1.0,
|
||||
integrations: [],
|
||||
});
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Outlet } from "react-router";
|
||||
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";
|
||||
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";
|
||||
|
||||
const IssuesClientLayout = observer((props: Route.ComponentProps) => {
|
||||
const { anchor } = props.params;
|
||||
// store hooks
|
||||
const { fetchPublishSettings } = usePublishList();
|
||||
const publishSettings = usePublish(anchor);
|
||||
const { updateLayoutOptions } = useIssueFilter();
|
||||
// fetch publish settings
|
||||
const { error } = useSWR(
|
||||
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
|
||||
anchor
|
||||
? async () => {
|
||||
const response = await fetchPublishSettings(anchor);
|
||||
if (response.view_props) {
|
||||
updateLayoutOptions({
|
||||
list: !!response.view_props.list,
|
||||
kanban: !!response.view_props.kanban,
|
||||
calendar: !!response.view_props.calendar,
|
||||
gantt: !!response.view_props.gantt,
|
||||
spreadsheet: !!response.view_props.spreadsheet,
|
||||
});
|
||||
}
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (!publishSettings && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen w-full">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) return <SomethingWentWrongError />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
|
||||
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
|
||||
<IssuesNavbarRoot publishSettings={publishSettings} />
|
||||
</div>
|
||||
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<PoweredBy />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default IssuesClientLayout;
|
||||
@@ -1,142 +1,54 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Outlet } from "react-router";
|
||||
import type { ShouldRevalidateFunctionArgs } from "react-router";
|
||||
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";
|
||||
import { IssuesNavbarRoot } from "@/components/issues/navbar";
|
||||
// hooks
|
||||
import { PageNotFound } from "@/components/ui/not-found";
|
||||
import { usePublish, usePublishList } from "@/hooks/store/publish";
|
||||
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
|
||||
import type { Route } from "./+types/layout";
|
||||
"use server";
|
||||
|
||||
const DEFAULT_TITLE = "Plane";
|
||||
const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities.";
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
params: {
|
||||
anchor: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface IssueMetadata {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover_image?: string;
|
||||
}
|
||||
|
||||
// Loader function runs on the server and fetches metadata
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
// TODO: Convert into SSR in order to generate metadata
|
||||
export async function generateMetadata({ params }: Props) {
|
||||
const { anchor } = params;
|
||||
|
||||
const DEFAULT_TITLE = "Plane";
|
||||
const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities.";
|
||||
// Validate anchor before using in request (only allow alphanumeric, -, _)
|
||||
const ANCHOR_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
if (!ANCHOR_REGEX.test(anchor)) {
|
||||
return { metadata: null };
|
||||
return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${process.env.VITE_API_BASE_URL}/api/public/anchor/${anchor}/meta/`);
|
||||
|
||||
if (!response.ok) {
|
||||
return { metadata: null };
|
||||
}
|
||||
|
||||
const metadata: IssueMetadata = await response.json();
|
||||
return { metadata };
|
||||
} catch (error) {
|
||||
console.error("Error fetching issue metadata:", error);
|
||||
return { metadata: null };
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/anchor/${anchor}/meta/`);
|
||||
const data = await response.json();
|
||||
return {
|
||||
title: data?.name || DEFAULT_TITLE,
|
||||
description: data?.description || DEFAULT_DESCRIPTION,
|
||||
openGraph: {
|
||||
title: data?.name || DEFAULT_TITLE,
|
||||
description: data?.description || DEFAULT_DESCRIPTION,
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: data?.cover_image,
|
||||
width: 800,
|
||||
height: 600,
|
||||
alt: data?.name || DEFAULT_TITLE,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: data?.name || DEFAULT_TITLE,
|
||||
description: data?.description || DEFAULT_DESCRIPTION,
|
||||
images: [data?.cover_image],
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION };
|
||||
}
|
||||
}
|
||||
|
||||
// Meta function uses the loader data to generate metadata
|
||||
export function meta({ loaderData }: Route.MetaArgs) {
|
||||
const metadata = loaderData?.metadata;
|
||||
|
||||
const title = metadata?.name || DEFAULT_TITLE;
|
||||
const description = metadata?.description || DEFAULT_DESCRIPTION;
|
||||
const coverImage = metadata?.cover_image;
|
||||
|
||||
const metaTags = [
|
||||
{ title },
|
||||
{ name: "description", content: description },
|
||||
// OpenGraph metadata
|
||||
{ property: "og:title", content: title },
|
||||
{ property: "og:description", content: description },
|
||||
{ property: "og:type", content: "website" },
|
||||
// Twitter metadata
|
||||
{ name: "twitter:card", content: "summary_large_image" },
|
||||
{ name: "twitter:title", content: title },
|
||||
{ name: "twitter:description", content: description },
|
||||
];
|
||||
|
||||
// Add images if cover image exists
|
||||
if (coverImage) {
|
||||
metaTags.push(
|
||||
{ property: "og:image", content: coverImage },
|
||||
{ property: "og:image:width", content: "800" },
|
||||
{ property: "og:image:height", content: "600" },
|
||||
{ property: "og:image:alt", content: title },
|
||||
{ name: "twitter:image", content: coverImage }
|
||||
);
|
||||
}
|
||||
|
||||
return metaTags;
|
||||
export default async function IssuesLayout(_props: Props) {
|
||||
// return <IssuesClientLayout params={{ anchor }}>{children}</IssuesClientLayout>;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prevent loader from re-running on anchor param changes
|
||||
export function shouldRevalidate({ currentParams, nextParams }: ShouldRevalidateFunctionArgs) {
|
||||
return currentParams.anchor !== nextParams.anchor;
|
||||
}
|
||||
|
||||
function IssuesLayout(props: Route.ComponentProps) {
|
||||
const { anchor } = props.params;
|
||||
// store hooks
|
||||
const { fetchPublishSettings } = usePublishList();
|
||||
const publishSettings = usePublish(anchor);
|
||||
const { updateLayoutOptions } = useIssueFilter();
|
||||
// fetch publish settings
|
||||
const { error } = useSWR(
|
||||
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
|
||||
anchor
|
||||
? async () => {
|
||||
const response = await fetchPublishSettings(anchor);
|
||||
if (response.view_props) {
|
||||
updateLayoutOptions({
|
||||
list: !!response.view_props.list,
|
||||
kanban: !!response.view_props.kanban,
|
||||
calendar: !!response.view_props.calendar,
|
||||
gantt: !!response.view_props.gantt,
|
||||
spreadsheet: !!response.view_props.spreadsheet,
|
||||
});
|
||||
}
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (!publishSettings && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen w-full">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error?.status === 404) return <PageNotFound />;
|
||||
|
||||
if (error) return <SomethingWentWrongError />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
|
||||
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
|
||||
<IssuesNavbarRoot publishSettings={publishSettings} />
|
||||
</div>
|
||||
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<PoweredBy />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(IssuesLayout);
|
||||
|
||||
+4
-23
@@ -1,13 +1,10 @@
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import { Links, Meta, Outlet, Scripts } from "react-router";
|
||||
import type { HeadersFunction, LinksFunction } 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 siteWebmanifest from "@/app/assets/favicon/site.webmanifest?url";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import globalStyles from "@/styles/globals.css?url";
|
||||
// types
|
||||
import type { Route } from "./+types/root";
|
||||
@@ -23,18 +20,10 @@ 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: siteWebmanifest },
|
||||
{ rel: "manifest", href: `/site.webmanifest.json` },
|
||||
{ rel: "stylesheet", href: globalStyles },
|
||||
];
|
||||
|
||||
export const headers: HeadersFunction = () => ({
|
||||
"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",
|
||||
});
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
@@ -73,17 +62,9 @@ export default function Root() {
|
||||
}
|
||||
|
||||
export function HydrateFallback() {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
if (error) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { index, layout, route } from "@react-router/dev/routes";
|
||||
export default [
|
||||
index("./page.tsx"),
|
||||
route(":workspaceSlug/:projectId", "./[workspaceSlug]/[projectId]/page.tsx"),
|
||||
layout("./issues/[anchor]/layout.tsx", [route("issues/:anchor", "./issues/[anchor]/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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
@@ -25,19 +25,19 @@ export const IssuePeekOverview: React.FC<TIssuePeekOverview> = observer((props)
|
||||
const state = searchParams.get("state") || undefined;
|
||||
const priority = searchParams.get("priority") || undefined;
|
||||
const labels = searchParams.get("labels") || undefined;
|
||||
// states
|
||||
const [isSidePeekOpen, setIsSidePeekOpen] = useState(false);
|
||||
const [isModalPeekOpen, setIsModalPeekOpen] = useState(false);
|
||||
// store
|
||||
const { peekMode, setPeekId, getIssueById, fetchIssueDetails } = useIssueDetails();
|
||||
// derived values
|
||||
const issueDetails = peekId ? getIssueById(peekId.toString()) : undefined;
|
||||
// state
|
||||
const isSidePeekOpen = !!peekId && peekMode === "side";
|
||||
const isModalPeekOpen = !!peekId && (peekMode === "modal" || peekMode === "full");
|
||||
const issueDetailStore = useIssueDetails();
|
||||
|
||||
const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (anchor && peekId) {
|
||||
fetchIssueDetails(anchor, peekId.toString());
|
||||
issueDetailStore.fetchIssueDetails(anchor, peekId.toString());
|
||||
}
|
||||
}, [anchor, fetchIssueDetails, peekId]);
|
||||
}, [anchor, issueDetailStore, peekId]);
|
||||
|
||||
const handleClose = () => {
|
||||
// if close logic is passed down, call that instead of the below logic
|
||||
@@ -46,7 +46,7 @@ export const IssuePeekOverview: React.FC<TIssuePeekOverview> = observer((props)
|
||||
return;
|
||||
}
|
||||
|
||||
setPeekId(null);
|
||||
issueDetailStore.setPeekId(null);
|
||||
let queryParams: any = {
|
||||
board,
|
||||
};
|
||||
@@ -57,6 +57,21 @@ export const IssuePeekOverview: React.FC<TIssuePeekOverview> = observer((props)
|
||||
router.push(`/issues/${anchor}?${queryParams}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (peekId) {
|
||||
if (issueDetailStore.peekMode === "side") {
|
||||
setIsSidePeekOpen(true);
|
||||
setIsModalPeekOpen(false);
|
||||
} else {
|
||||
setIsModalPeekOpen(true);
|
||||
setIsSidePeekOpen(false);
|
||||
}
|
||||
} else {
|
||||
setIsSidePeekOpen(false);
|
||||
setIsModalPeekOpen(false);
|
||||
}
|
||||
}, [peekId, issueDetailStore.peekMode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition.Root appear show={isSidePeekOpen} as={Fragment}>
|
||||
@@ -101,13 +116,13 @@ export const IssuePeekOverview: React.FC<TIssuePeekOverview> = observer((props)
|
||||
<Dialog.Panel>
|
||||
<div
|
||||
className={`fixed left-1/2 top-1/2 z-20 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-custom-background-100 shadow-custom-shadow-xl transition-all duration-300 ${
|
||||
peekMode === "modal" ? "h-[70%] w-3/5" : "h-[95%] w-[95%]"
|
||||
issueDetailStore.peekMode === "modal" ? "h-[70%] w-3/5" : "h-[95%] w-[95%]"
|
||||
}`}
|
||||
>
|
||||
{peekMode === "modal" && (
|
||||
{issueDetailStore.peekMode === "modal" && (
|
||||
<SidePeekView anchor={anchor} handleClose={handleClose} issueDetails={issueDetails} />
|
||||
)}
|
||||
{peekMode === "full" && (
|
||||
{issueDetailStore.peekMode === "full" && (
|
||||
<FullScreenPeekView anchor={anchor} handleClose={handleClose} issueDetails={issueDetails} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,7 @@ const PROGRESS_CONFIG: Readonly<ProgressConfig> = {
|
||||
easing: "ease",
|
||||
trickle: true,
|
||||
delay: 0,
|
||||
isDisabled: import.meta.env.PROD, // Disable progress bar in production builds
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
// helpers
|
||||
import { SUPPORT_EMAIL } from "@plane/constants";
|
||||
import { SUPPORT_EMAIL } from "./common.helper";
|
||||
|
||||
export enum EPageTypes {
|
||||
INIT = "INIT",
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "";
|
||||
|
||||
export const resolveGeneralTheme = (resolvedTheme: string | undefined) =>
|
||||
resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
+16
-6
@@ -5,10 +5,10 @@
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "react-router dev --port 3002",
|
||||
"dev": "cross-env NODE_ENV=development PORT=3002 node server.mjs",
|
||||
"build": "react-router build",
|
||||
"preview": "react-router build && PORT=3002 react-router-serve ./build/server/index.js",
|
||||
"start": "PORT=3002 react-router-serve ./build/server/index.js",
|
||||
"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": "react-router typegen && tsc --noEmit",
|
||||
@@ -30,18 +30,24 @@
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@react-router/express": "^7.9.3",
|
||||
"@react-router/node": "^7.9.3",
|
||||
"@react-router/serve": "^7.9.5",
|
||||
"@sentry/react-router": "catalog:",
|
||||
"@vercel/edge": "1.2.2",
|
||||
"axios": "catalog:",
|
||||
"clsx": "^2.0.0",
|
||||
"compression": "^1.8.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.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:",
|
||||
"mobx-utils": "catalog:",
|
||||
"morgan": "^1.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
@@ -50,6 +56,7 @@
|
||||
"react-popper": "^2.3.0",
|
||||
"react-router": "^7.9.1",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"serve": "14.2.5",
|
||||
"swr": "catalog:",
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
@@ -58,12 +65,15 @@
|
||||
"@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:",
|
||||
"vite": "catalog:",
|
||||
"vite": "7.1.7",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
|
||||
const basePath = joinUrlPath(process.env.VITE_SPACE_BASE_PATH ?? "", "/") ?? "/";
|
||||
|
||||
export default {
|
||||
appDirectory: "app",
|
||||
basename: basePath,
|
||||
ssr: true,
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
+50
-26
@@ -4,32 +4,56 @@ import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
|
||||
// Expose only vars starting with VITE_
|
||||
const viteEnv = Object.keys(process.env)
|
||||
.filter((k) => k.startsWith("VITE_"))
|
||||
.reduce<Record<string, string>>((a, k) => {
|
||||
a[k] = process.env[k] ?? "";
|
||||
return a;
|
||||
}, {});
|
||||
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 basePath = joinUrlPath(process.env.VITE_SPACE_BASE_PATH ?? "", "/") ?? "/";
|
||||
const publicEnv = PUBLIC_ENV_KEYS.reduce<Record<string, string>>((acc, key) => {
|
||||
acc[key] = process.env[key] ?? "";
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export default defineConfig(() => ({
|
||||
base: basePath,
|
||||
define: {
|
||||
"process.env": JSON.stringify(viteEnv),
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
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"),
|
||||
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),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
}));
|
||||
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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
VITE_API_BASE_URL="http://localhost:8000"
|
||||
NEXT_PUBLIC_API_BASE_URL="http://localhost:8000"
|
||||
|
||||
VITE_WEB_BASE_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000"
|
||||
|
||||
VITE_ADMIN_BASE_URL="http://localhost:3001"
|
||||
VITE_ADMIN_BASE_PATH="/god-mode"
|
||||
NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001"
|
||||
NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
|
||||
VITE_SPACE_BASE_URL="http://localhost:3002"
|
||||
VITE_SPACE_BASE_PATH="/spaces"
|
||||
NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002"
|
||||
NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
|
||||
VITE_LIVE_BASE_URL="http://localhost:3100"
|
||||
VITE_LIVE_BASE_PATH="/live"
|
||||
NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100"
|
||||
NEXT_PUBLIC_LIVE_BASE_PATH="/live"
|
||||
|
||||
+17
-17
@@ -39,31 +39,31 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
|
||||
|
||||
ARG VITE_API_BASE_URL=""
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
ARG VITE_ADMIN_BASE_URL=""
|
||||
ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
|
||||
|
||||
ARG VITE_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_BASE_PATH
|
||||
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
|
||||
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
|
||||
ARG VITE_LIVE_BASE_URL=""
|
||||
ENV VITE_LIVE_BASE_URL=$VITE_LIVE_BASE_URL
|
||||
ARG NEXT_PUBLIC_LIVE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_LIVE_BASE_URL=$NEXT_PUBLIC_LIVE_BASE_URL
|
||||
|
||||
ARG VITE_LIVE_BASE_PATH="/live"
|
||||
ENV VITE_LIVE_BASE_PATH=$VITE_LIVE_BASE_PATH
|
||||
ARG NEXT_PUBLIC_LIVE_BASE_PATH="/live"
|
||||
ENV NEXT_PUBLIC_LIVE_BASE_PATH=$NEXT_PUBLIC_LIVE_BASE_PATH
|
||||
|
||||
ARG VITE_SPACE_BASE_URL=""
|
||||
ENV VITE_SPACE_BASE_URL=$VITE_SPACE_BASE_URL
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
|
||||
|
||||
ARG VITE_SPACE_BASE_PATH="/spaces"
|
||||
ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
|
||||
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
|
||||
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
|
||||
|
||||
ARG VITE_WEB_BASE_URL=""
|
||||
ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
|
||||
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
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
// };
|
||||
|
||||
// const urls = [
|
||||
// `${process.env.VITE_API_BASE_URL}/api/instances/`,
|
||||
// `${process.env.VITE_API_BASE_URL}/api/users/me/`,
|
||||
// `${process.env.VITE_API_BASE_URL}/api/users/me/profile/`,
|
||||
// `${process.env.VITE_API_BASE_URL}/api/users/me/settings/`,
|
||||
// `${process.env.VITE_API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`,
|
||||
// `${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));
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/* eslint-disable import/order */
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.VITE_SENTRY_DSN,
|
||||
environment: process.env.VITE_SENTRY_ENVIRONMENT,
|
||||
sendDefaultPii: process.env.VITE_SENTRY_SEND_DEFAULT_PII ? process.env.VITE_SENTRY_SEND_DEFAULT_PII === "1" : false,
|
||||
release: process.env.VITE_APP_VERSION,
|
||||
tracesSampleRate: process.env.VITE_SENTRY_TRACES_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_TRACES_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
profilesSampleRate: process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
replaysSessionSampleRate: process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
replaysOnErrorSampleRate: process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE)
|
||||
: 1.0,
|
||||
integrations: [],
|
||||
});
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>
|
||||
);
|
||||
});
|
||||
@@ -50,7 +50,7 @@ export const meta = () => [
|
||||
];
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const isSessionRecorderEnabled = parseInt(process.env.VITE_ENABLE_SESSION_RECORDER || "0");
|
||||
const isSessionRecorderEnabled = parseInt(process.env.NEXT_PUBLIC_ENABLE_SESSION_RECORDER || "0");
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
@@ -86,16 +86,16 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
</div>
|
||||
</AppProvider>
|
||||
</body>
|
||||
{process.env.VITE_PLAUSIBLE_DOMAIN && (
|
||||
<Script defer data-domain={process.env.VITE_PLAUSIBLE_DOMAIN} src="https://plausible.io/js/script.js" />
|
||||
{process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && (
|
||||
<Script defer data-domain={process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN} src="https://plausible.io/js/script.js" />
|
||||
)}
|
||||
{!!isSessionRecorderEnabled && process.env.VITE_SESSION_RECORDER_KEY && (
|
||||
{!!isSessionRecorderEnabled && process.env.NEXT_PUBLIC_SESSION_RECORDER_KEY && (
|
||||
<Script id="clarity-tracking">
|
||||
{`(function(c,l,a,r,i,t,y){
|
||||
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||
y=l.getElementsByTagName(r)[0];if(y){y.parentNode.insertBefore(t,y);}
|
||||
})(window, document, "clarity", "script", "${process.env.VITE_SESSION_RECORDER_KEY}");`}
|
||||
})(window, document, "clarity", "script", "${process.env.NEXT_PUBLIC_SESSION_RECORDER_KEY}");`}
|
||||
</Script>
|
||||
)}
|
||||
</html>
|
||||
|
||||
+10
-16
@@ -1,5 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import Script from "next/script";
|
||||
import { Links, Meta, Outlet, Scripts } from "react-router";
|
||||
import type { LinksFunction } from "react-router";
|
||||
@@ -14,7 +13,6 @@ 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 { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import globalStyles from "@/styles/globals.css?url";
|
||||
import type { Route } from "./+types/root";
|
||||
// local
|
||||
@@ -36,7 +34,7 @@ export const links: LinksFunction = () => [
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
const isSessionRecorderEnabled = parseInt(process.env.VITE_ENABLE_SESSION_RECORDER || "0");
|
||||
const isSessionRecorderEnabled = parseInt(process.env.NEXT_PUBLIC_ENABLE_SESSION_RECORDER || "0");
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
@@ -68,16 +66,20 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
</AppProvider>
|
||||
<Scripts />
|
||||
{process.env.VITE_PLAUSIBLE_DOMAIN && (
|
||||
<Script defer data-domain={process.env.VITE_PLAUSIBLE_DOMAIN} src="https://plausible.io/js/script.js" />
|
||||
{process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && (
|
||||
<Script
|
||||
defer
|
||||
data-domain={process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
|
||||
src="https://plausible.io/js/script.js"
|
||||
/>
|
||||
)}
|
||||
{!!isSessionRecorderEnabled && process.env.VITE_SESSION_RECORDER_KEY && (
|
||||
{!!isSessionRecorderEnabled && process.env.NEXT_PUBLIC_SESSION_RECORDER_KEY && (
|
||||
<Script id="clarity-tracking">
|
||||
{`(function(c,l,a,r,i,t,y){
|
||||
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||
y=l.getElementsByTagName(r)[0];if(y){y.parentNode.insertBefore(t,y);}
|
||||
})(window, document, "clarity", "script", "${process.env.VITE_SESSION_RECORDER_KEY}");`}
|
||||
})(window, document, "clarity", "script", "${process.env.NEXT_PUBLIC_SESSION_RECORDER_KEY}");`}
|
||||
</Script>
|
||||
)}
|
||||
</body>
|
||||
@@ -116,17 +118,9 @@ export default function Root() {
|
||||
}
|
||||
|
||||
export function HydrateFallback() {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
if (error) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
|
||||
return <CustomErrorComponent error={error} />;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export const SelectRepository: React.FC<Props> = (props) => {
|
||||
const getKey = (pageIndex: number) => {
|
||||
if (!workspaceSlug || !integration) return;
|
||||
|
||||
return `${process.env.VITE_API_BASE_URL}/api/workspaces/${workspaceSlug}/workspace-integrations/${
|
||||
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/workspaces/${workspaceSlug}/workspace-integrations/${
|
||||
integration.id
|
||||
}/github-repositories/?page=${++pageIndex}`;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { xor } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
@@ -273,7 +272,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
|
||||
if (isDraft) await draftIssues.updateIssue(workspaceSlug.toString(), data.id, payload);
|
||||
else if (updateIssue) await updateIssue(payload.project_id, data.id, payload);
|
||||
|
||||
// check if we should add/remove issue to/from cycle
|
||||
// check if we should add issue to cycle/module
|
||||
if (
|
||||
payload.cycle_id &&
|
||||
payload.cycle_id !== "" &&
|
||||
@@ -281,30 +280,12 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
|
||||
) {
|
||||
await addIssueToCycle(data as TBaseIssue, payload.cycle_id);
|
||||
}
|
||||
if (data.cycle_id && !payload.cycle_id && data.project_id) {
|
||||
await issues.removeIssueFromCycle(workspaceSlug.toString(), data.project_id, data.cycle_id, data.id);
|
||||
fetchCycleDetails(workspaceSlug.toString(), data.project_id, data.cycle_id);
|
||||
}
|
||||
|
||||
if (data.module_ids && payload.module_ids && data.project_id) {
|
||||
const updatedModuleIds = xor(data.module_ids, payload.module_ids);
|
||||
const modulesToAdd: string[] = [];
|
||||
const modulesToRemove: string[] = [];
|
||||
|
||||
for (const moduleId of updatedModuleIds) {
|
||||
if (data.module_ids.includes(moduleId)) {
|
||||
modulesToRemove.push(moduleId);
|
||||
} else {
|
||||
modulesToAdd.push(moduleId);
|
||||
}
|
||||
}
|
||||
await issues.changeModulesInIssue(
|
||||
workspaceSlug.toString(),
|
||||
data.project_id,
|
||||
data.id,
|
||||
modulesToAdd,
|
||||
modulesToRemove
|
||||
);
|
||||
if (
|
||||
payload.module_ids &&
|
||||
payload.module_ids.length > 0 &&
|
||||
(!payload.module_ids.includes(moduleId?.toString()) || storeType !== EIssuesStoreType.MODULE)
|
||||
) {
|
||||
await addIssueToModule(data as TBaseIssue, payload.module_ids);
|
||||
}
|
||||
|
||||
// add other property values
|
||||
|
||||
@@ -12,7 +12,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IProject, IUserLite, IWorkspace } from "@plane/types";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
// constants
|
||||
import { PROJECT_DETAILS } from "@/constants/fetch-keys";
|
||||
import { PROJECT_MEMBERS } from "@/constants/fetch-keys";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
@@ -64,7 +64,7 @@ export const ProjectSettingsMemberDefaults: React.FC<TProjectSettingsMemberDefau
|
||||
const { reset, control } = useForm<IProject>({ defaultValues });
|
||||
// fetching user members
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
// plane editor
|
||||
import { convertBinaryDataToBase64String, getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
|
||||
import { getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
// plane types
|
||||
import type { TDocumentPayload } from "@plane/types";
|
||||
@@ -34,7 +34,7 @@ export const usePageFallback = (args: TArgs) => {
|
||||
editor.setProviderDocument(latestDecodedDescription);
|
||||
const { binary, html, json } = editor.getDocument();
|
||||
if (!binary || !json) return;
|
||||
const encodedBinary = convertBinaryDataToBase64String(binary);
|
||||
const encodedBinary = Buffer.from(binary).toString("base64");
|
||||
|
||||
await updatePageDescription({
|
||||
description_binary: encodedBinary,
|
||||
|
||||
@@ -38,6 +38,7 @@ const PROGRESS_CONFIG: Readonly<ProgressConfig> = {
|
||||
easing: "ease",
|
||||
trickle: true,
|
||||
delay: 0,
|
||||
isDisabled: import.meta.env.PROD, // Disable progress bar in production builds
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { PostHogProvider as PHProvider } from "@posthog/react";
|
||||
import { lazy, Suspense, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import { PostHogProvider as PHProvider } from "posthog-js/react";
|
||||
// constants
|
||||
import { GROUP_WORKSPACE_TRACKER_EVENT } from "@plane/constants";
|
||||
// helpers
|
||||
@@ -29,19 +29,18 @@ const PostHogProvider: FC<IPosthogWrapper> = observer((props) => {
|
||||
const { instance } = useInstance();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const { getWorkspaceRoleByWorkspaceSlug, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
|
||||
// states
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
// derived values
|
||||
|
||||
const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(
|
||||
workspaceSlug?.toString(),
|
||||
projectId?.toString()
|
||||
);
|
||||
const currentWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug?.toString());
|
||||
const is_telemetry_enabled = instance?.is_telemetry_enabled || false;
|
||||
const is_posthog_enabled = process.env.VITE_POSTHOG_KEY && process.env.VITE_POSTHOG_HOST && is_telemetry_enabled;
|
||||
const is_posthog_enabled =
|
||||
process.env.NEXT_PUBLIC_POSTHOG_KEY && process.env.NEXT_PUBLIC_POSTHOG_HOST && is_telemetry_enabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (user && hydrated) {
|
||||
if (user) {
|
||||
// Identify sends an event, so you want may want to limit how often you call it
|
||||
posthog?.identify(user.email, {
|
||||
id: user.id,
|
||||
@@ -58,23 +57,19 @@ const PostHogProvider: FC<IPosthogWrapper> = observer((props) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [user, currentProjectRole, currentWorkspaceRole, currentWorkspace, hydrated]);
|
||||
}, [user, currentProjectRole, currentWorkspaceRole, currentWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
const posthogKey = process.env.VITE_POSTHOG_KEY;
|
||||
const posthogHost = process.env.VITE_POSTHOG_HOST;
|
||||
const isDebugMode = process.env.VITE_POSTHOG_DEBUG === "1";
|
||||
if (posthogKey && posthogHost) {
|
||||
posthog.init(posthogKey, {
|
||||
api_host: posthogHost,
|
||||
ui_host: posthogHost,
|
||||
debug: isDebugMode, // Debug mode based on the environment variable
|
||||
if (process.env.NEXT_PUBLIC_POSTHOG_KEY && process.env.NEXT_PUBLIC_POSTHOG_HOST) {
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
api_host: "/ingest",
|
||||
ui_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
debug: process.env.NEXT_PUBLIC_POSTHOG_DEBUG === "1", // Debug mode based on the environment variable
|
||||
autocapture: false,
|
||||
capture_pageview: false, // Disable automatic pageview capture, as we capture manually
|
||||
capture_pageleave: true,
|
||||
disable_session_recording: true,
|
||||
});
|
||||
setHydrated(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -91,16 +86,16 @@ const PostHogProvider: FC<IPosthogWrapper> = observer((props) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (is_posthog_enabled && hydrated) {
|
||||
if (is_posthog_enabled) {
|
||||
document.addEventListener("click", clickHandler);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", clickHandler);
|
||||
};
|
||||
}, [hydrated, is_posthog_enabled]);
|
||||
}, [is_posthog_enabled]);
|
||||
|
||||
if (is_posthog_enabled && hydrated)
|
||||
if (is_posthog_enabled)
|
||||
return (
|
||||
<PHProvider client={posthog}>
|
||||
<Suspense>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
/** @type {import("next").NextConfig} */
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require("dotenv").config({ path: ".env" });
|
||||
|
||||
const nextConfig = {
|
||||
trailingSlash: true,
|
||||
reactStrictMode: false,
|
||||
swcMinify: true,
|
||||
output: "standalone",
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)?",
|
||||
headers: [{ key: "X-Frame-Options", value: "DENY" }],
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
experimental: {
|
||||
optimizePackageImports: [
|
||||
"lucide-react",
|
||||
"date-fns",
|
||||
"@headlessui/react",
|
||||
"react-color",
|
||||
"react-day-picker",
|
||||
"react-dropzone",
|
||||
"react-hook-form",
|
||||
"lodash-es",
|
||||
"clsx",
|
||||
"tailwind-merge",
|
||||
"@plane/constants",
|
||||
"@plane/editor",
|
||||
"@plane/hooks",
|
||||
"@plane/i18n",
|
||||
"@plane/logger",
|
||||
"@plane/propel",
|
||||
"@plane/services",
|
||||
"@plane/shared-state",
|
||||
"@plane/types",
|
||||
"@plane/ui",
|
||||
"@plane/utils",
|
||||
],
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/:workspaceSlug/projects/:projectId/settings/:path*",
|
||||
destination: "/:workspaceSlug/settings/projects/:projectId/:path*",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/:workspaceSlug/analytics",
|
||||
destination: "/:workspaceSlug/analytics/overview",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/:workspaceSlug/settings/api-tokens",
|
||||
destination: "/:workspaceSlug/settings/account/api-tokens",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/:workspaceSlug/projects/:projectId/inbox",
|
||||
destination: "/:workspaceSlug/projects/:projectId/intake",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/accounts/sign-up",
|
||||
destination: "/sign-up",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/sign-in",
|
||||
destination: "/",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/signin",
|
||||
destination: "/",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/register",
|
||||
destination: "/sign-up",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/login",
|
||||
destination: "/",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://app.posthog.com";
|
||||
const rewrites = [
|
||||
{
|
||||
source: "/ingest/static/:path*",
|
||||
destination: `${posthogHost}/static/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/ingest/:path*",
|
||||
destination: `${posthogHost}/:path*`,
|
||||
},
|
||||
];
|
||||
if (process.env.NEXT_PUBLIC_ADMIN_BASE_URL || process.env.NEXT_PUBLIC_ADMIN_BASE_PATH) {
|
||||
const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
|
||||
const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
|
||||
const GOD_MODE_BASE_URL = ADMIN_BASE_URL + ADMIN_BASE_PATH;
|
||||
rewrites.push({
|
||||
source: "/god-mode",
|
||||
destination: `${GOD_MODE_BASE_URL}/`,
|
||||
});
|
||||
rewrites.push({
|
||||
source: "/god-mode/:path*",
|
||||
destination: `${GOD_MODE_BASE_URL}/:path*`,
|
||||
});
|
||||
}
|
||||
return rewrites;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
+20
-12
@@ -5,9 +5,9 @@
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "react-router dev --port 3000",
|
||||
"dev": "cross-env NODE_ENV=development PORT=3000 node server.mjs",
|
||||
"build": "react-router build",
|
||||
"preview": "react-router build && serve -s build/client -l 3000",
|
||||
"preview": "react-router build && cross-env NODE_ENV=production PORT=3000 node server.mjs",
|
||||
"start": "serve -s build/client -l 3000",
|
||||
"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 821",
|
||||
@@ -17,9 +17,9 @@
|
||||
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "catalog:",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "catalog:",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "catalog:",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "catalog:",
|
||||
"@bprogress/core": "catalog:",
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@intercom/messenger-js-sdk": "^0.0.12",
|
||||
@@ -34,27 +34,31 @@
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@posthog/react": "^1.4.0",
|
||||
"@react-pdf/renderer": "^3.4.5",
|
||||
"@react-router/express": "^7.9.3",
|
||||
"@react-router/node": "^7.9.3",
|
||||
"@sentry/react-router": "catalog:",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "catalog:",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^1.0.0",
|
||||
"comlink": "^4.4.1",
|
||||
"compression": "^1.8.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"emoji-picker-react": "^4.5.16",
|
||||
"export-to-csv": "^1.4.0",
|
||||
"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:",
|
||||
"mobx-utils": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"morgan": "^1.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
"react": "catalog:",
|
||||
"posthog-js": "^1.131.3",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "catalog:",
|
||||
"react-dropzone": "^14.2.3",
|
||||
@@ -65,8 +69,9 @@
|
||||
"react-masonry-component": "^6.3.0",
|
||||
"react-pdf-html": "^2.1.2",
|
||||
"react-popper": "^2.3.0",
|
||||
"react-router": "^7.9.1",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"react-router": "^7.9.1",
|
||||
"react": "catalog:",
|
||||
"recharts": "^2.12.7",
|
||||
"serve": "14.2.5",
|
||||
"smooth-scroll-into-view-if-needed": "^2.0.2",
|
||||
@@ -80,14 +85,17 @@
|
||||
"@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-color": "^3.0.6",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vite": "7.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
appDirectory: "app",
|
||||
basename: process.env.NEXT_PUBLIC_WEB_BASE_PATH,
|
||||
// Web runs as a client-side app; build a static client bundle only
|
||||
ssr: false,
|
||||
} satisfies Config;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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_WEB_BASE_URL when available, otherwise
|
||||
// default to http://localhost:3000 and fall back to PORT env if explicitly set.
|
||||
const DEFAULT_BASE_URL = "http://localhost:3000";
|
||||
const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || DEFAULT_BASE_URL;
|
||||
let parsedBaseUrl;
|
||||
try {
|
||||
parsedBaseUrl = new URL(WEB_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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import "react-router";
|
||||
import { createRequestHandler } from "@react-router/express";
|
||||
import express from "express";
|
||||
import type { Express } from "express";
|
||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
import type { ServerBuild } from "react-router";
|
||||
|
||||
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_WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH
|
||||
? process.env.NEXT_PUBLIC_WEB_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") as Promise<ServerBuild>,
|
||||
})
|
||||
);
|
||||
|
||||
app.use(NEXT_PUBLIC_WEB_BASE_PATH, router);
|
||||
+60
-26
@@ -3,31 +3,65 @@ import { reactRouter } from "@react-router/dev/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
// Expose only vars starting with VITE_
|
||||
const viteEnv = Object.keys(process.env)
|
||||
.filter((k) => k.startsWith("VITE_"))
|
||||
.reduce<Record<string, string>>((a, k) => {
|
||||
a[k] = process.env[k] ?? "";
|
||||
return a;
|
||||
}, {});
|
||||
const PUBLIC_ENV_KEYS = [
|
||||
"ENABLE_EXPERIMENTAL_COREPACK",
|
||||
"NEXT_PUBLIC_ADMIN_BASE_PATH",
|
||||
"NEXT_PUBLIC_ADMIN_BASE_URL",
|
||||
"NEXT_PUBLIC_API_BASE_PATH",
|
||||
"NEXT_PUBLIC_API_BASE_URL",
|
||||
"NEXT_PUBLIC_CRISP_ID",
|
||||
"NEXT_PUBLIC_ENABLE_SESSION_RECORDER",
|
||||
"NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS",
|
||||
"NEXT_PUBLIC_LIVE_BASE_PATH",
|
||||
"NEXT_PUBLIC_LIVE_BASE_URL",
|
||||
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
||||
"NEXT_PUBLIC_POSTHOG_DEBUG",
|
||||
"NEXT_PUBLIC_POSTHOG_HOST",
|
||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||
"NEXT_PUBLIC_SESSION_RECORDER_KEY",
|
||||
"NEXT_PUBLIC_SPACE_BASE_PATH",
|
||||
"NEXT_PUBLIC_SPACE_BASE_URL",
|
||||
"NEXT_PUBLIC_SUPPORT_EMAIL",
|
||||
"NEXT_PUBLIC_WEB_BASE_PATH",
|
||||
"NEXT_PUBLIC_WEB_BASE_URL",
|
||||
"NEXT_PUBLIC_WEBSITE_URL",
|
||||
"NODE_ENV",
|
||||
];
|
||||
|
||||
export default defineConfig(() => ({
|
||||
define: {
|
||||
"process.env": JSON.stringify(viteEnv),
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
plugins: [reactRouter(), tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] })],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Next.js compatibility shims used within web
|
||||
"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"),
|
||||
"next/script": path.resolve(__dirname, "app/compat/next/script.tsx"),
|
||||
const publicEnv = PUBLIC_ENV_KEYS.reduce<Record<string, string>>((acc, key) => {
|
||||
acc[key] = process.env[key] ?? "";
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export default defineConfig(({ isSsrBuild }) => {
|
||||
// Only produce an SSR bundle when explicitly enabled.
|
||||
// For static deployments (default), we skip the server build entirely.
|
||||
const enableSsrBuild = process.env.WEB_ENABLE_SSR_BUILD === "true";
|
||||
|
||||
return {
|
||||
define: {
|
||||
"process.env": JSON.stringify(publicEnv),
|
||||
},
|
||||
dedupe: ["react", "react-dom", "@headlessui/react"],
|
||||
},
|
||||
// No SSR-specific overrides needed; alias resolves to ESM build
|
||||
}));
|
||||
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 web
|
||||
"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"),
|
||||
"next/script": path.resolve(__dirname, "app/compat/next/script.tsx"),
|
||||
},
|
||||
dedupe: ["react", "react-dom", "@headlessui/react"],
|
||||
},
|
||||
// No SSR-specific overrides needed; alias resolves to ESM build
|
||||
};
|
||||
});
|
||||
|
||||
@@ -141,7 +141,6 @@ function updateEnvFile() {
|
||||
value=$(echo "$value" | sed 's/|/\\|/g')
|
||||
sed -i '' "s|^$key=.*|$key=$value|g" "$file"
|
||||
else
|
||||
value=$(echo "$value" | sed 's/\//\\\//g')
|
||||
sed -i "s/^$key=.*/$key=$value/g" "$file"
|
||||
fi
|
||||
fi
|
||||
|
||||
+4
-2
@@ -19,7 +19,7 @@
|
||||
"devDependencies": {
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"turbo": "2.6.1"
|
||||
"turbo": "^2.5.8"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
@@ -34,17 +34,19 @@
|
||||
"prosemirror-view": "1.40.0",
|
||||
"@types/express": "4.17.23",
|
||||
"typescript": "catalog:",
|
||||
"sharp": "catalog:",
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@swc/core",
|
||||
"core-js",
|
||||
"esbuild",
|
||||
"sharp",
|
||||
"turbo",
|
||||
"unrs-resolver"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.21.0",
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
export const API_BASE_URL = process.env.VITE_API_BASE_URL || "";
|
||||
export const API_BASE_PATH = process.env.VITE_API_BASE_PATH || "";
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
|
||||
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "";
|
||||
export const API_URL = encodeURI(`${API_BASE_URL}${API_BASE_PATH}`);
|
||||
// God Mode Admin App Base Url
|
||||
export const ADMIN_BASE_URL = process.env.VITE_ADMIN_BASE_URL || "";
|
||||
export const ADMIN_BASE_PATH = process.env.VITE_ADMIN_BASE_PATH || "";
|
||||
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
|
||||
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
|
||||
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}`);
|
||||
// Publish App Base Url
|
||||
export const SPACE_BASE_URL = process.env.VITE_SPACE_BASE_URL || "";
|
||||
export const SPACE_BASE_PATH = process.env.VITE_SPACE_BASE_PATH || "";
|
||||
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
|
||||
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
|
||||
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}`);
|
||||
// Live App Base Url
|
||||
export const LIVE_BASE_URL = process.env.VITE_LIVE_BASE_URL || "";
|
||||
export const LIVE_BASE_PATH = process.env.VITE_LIVE_BASE_PATH || "";
|
||||
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
|
||||
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
|
||||
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}`);
|
||||
// Web App Base Url
|
||||
export const WEB_BASE_URL = process.env.VITE_WEB_BASE_URL || "";
|
||||
export const WEB_BASE_PATH = process.env.VITE_WEB_BASE_PATH || "";
|
||||
export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
|
||||
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "";
|
||||
export const WEB_URL = encodeURI(`${WEB_BASE_URL}${WEB_BASE_PATH}`);
|
||||
// plane website url
|
||||
export const WEBSITE_URL = process.env.VITE_WEBSITE_URL || "https://plane.so";
|
||||
export const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
|
||||
// support email
|
||||
export const SUPPORT_EMAIL = process.env.VITE_SUPPORT_EMAIL || "support@plane.so";
|
||||
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
|
||||
// marketing links
|
||||
export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing";
|
||||
export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact";
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-blockquote": "^2.22.3",
|
||||
"@tiptap/extension-character-count": "^2.22.3",
|
||||
"buffer": "^6.0.3",
|
||||
"@tiptap/extension-collaboration": "^2.22.3",
|
||||
"@tiptap/extension-emoji": "^2.22.3",
|
||||
"@tiptap/extension-image": "^2.22.3",
|
||||
|
||||
@@ -121,7 +121,6 @@ export const EmojisListDropdown = forwardRef<EmojiListRef, EmojisListDropdownPro
|
||||
/>
|
||||
<div
|
||||
ref={dropdownContainerRef}
|
||||
id="emojis-picker"
|
||||
className={cn(
|
||||
"relative max-h-80 w-[14rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2 opacity-0 invisible transition-opacity",
|
||||
{
|
||||
|
||||
@@ -79,7 +79,6 @@ export const pasteRegex = /:([a-zA-Z0-9_+-]+):/g;
|
||||
|
||||
export const Emoji = Node.create<EmojiOptions, EmojiStorage>({
|
||||
name: "emoji",
|
||||
priority: 1001,
|
||||
|
||||
inline: true,
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Buffer } from "buffer";
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
||||
|
||||
@@ -19,13 +19,6 @@ export const CoreEditorProps = (props: TArgs): EditorProps => {
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
// prevent default event listeners from firing when slash command is active
|
||||
if (["Enter"].includes(event.key)) {
|
||||
const emojisPicker = document.querySelector("#emojis-picker");
|
||||
if (emojisPicker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||
const slashCommand = document.querySelector("#slash-command");
|
||||
if (slashCommand) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import { ChevronDownIcon } from "../icons/arrows/chevron-down";
|
||||
import { ChevronDownIcon } from "../icons";
|
||||
import { Collapsible } from "./collapsible";
|
||||
|
||||
const meta = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Copy, Download, Edit, Share, Trash, Star, Archive } from "lucide-react";
|
||||
import { ChevronRightIcon } from "../icons/arrows/chevron-right";
|
||||
import { ChevronRightIcon } from "../icons";
|
||||
import { ContextMenu } from "./context-menu";
|
||||
|
||||
// cannot use satisfies here because base-ui does not have portable types.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import { CloseIcon } from "../icons/actions/close-icon";
|
||||
import { CloseIcon } from "../icons";
|
||||
import { Dialog, EDialogWidth } from "./root";
|
||||
|
||||
const meta = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import { CloseIcon } from "../icons/actions/close-icon";
|
||||
import { CloseIcon } from "../icons";
|
||||
import { Popover } from "./root";
|
||||
|
||||
// cannot use satifies here because base-ui does not have portable types.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Settings, User, Bell } from "lucide-react";
|
||||
import { HomeIcon } from "../icons/workspace/home-icon";
|
||||
import { HomeIcon } from "../icons";
|
||||
import { Tabs } from "./tabs";
|
||||
|
||||
type TabOption = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Strikethrough,
|
||||
Code,
|
||||
Link,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
AlignLeft,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
Globe2,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { ListLayoutIcon } from "../icons/layouts/list-icon";
|
||||
import { Toolbar } from "./toolbar";
|
||||
|
||||
const meta = {
|
||||
@@ -51,8 +51,8 @@ export const Default: Story = {
|
||||
<Toolbar.Item icon={Strikethrough} tooltip="Strikethrough" />
|
||||
</Toolbar.Group>
|
||||
<Toolbar.Group>
|
||||
<Toolbar.Item icon={ListLayoutIcon} tooltip="Bullet ListLayoutIcon" />
|
||||
<Toolbar.Item icon={ListOrdered} tooltip="Numbered ListLayoutIcon" />
|
||||
<Toolbar.Item icon={List} tooltip="Bullet List" />
|
||||
<Toolbar.Item icon={ListOrdered} tooltip="Numbered List" />
|
||||
<Toolbar.Item icon={Quote} tooltip="Quote" />
|
||||
</Toolbar.Group>
|
||||
<Toolbar.Group>
|
||||
@@ -82,8 +82,8 @@ export const WithActiveStates: Story = {
|
||||
<Toolbar.Item icon={Underline} tooltip="Underline" shortcut={["Cmd", "U"]} isActive />
|
||||
</Toolbar.Group>
|
||||
<Toolbar.Group>
|
||||
<Toolbar.Item icon={ListLayoutIcon} tooltip="Bullet ListLayoutIcon" />
|
||||
<Toolbar.Item icon={ListOrdered} tooltip="Numbered ListLayoutIcon" isActive />
|
||||
<Toolbar.Item icon={List} tooltip="Bullet List" />
|
||||
<Toolbar.Item icon={ListOrdered} tooltip="Numbered List" isActive />
|
||||
<Toolbar.Item icon={Quote} tooltip="Quote" />
|
||||
</Toolbar.Group>
|
||||
<Toolbar.Group>
|
||||
@@ -118,8 +118,8 @@ export const CommentToolbar: Story = {
|
||||
<Toolbar.Item icon={Code} tooltip="Code" shortcut={["Cmd", "`"]} />
|
||||
</Toolbar.Group>
|
||||
<Toolbar.Group>
|
||||
<Toolbar.Item icon={ListLayoutIcon} tooltip="Bullet ListLayoutIcon" />
|
||||
<Toolbar.Item icon={ListOrdered} tooltip="Numbered ListLayoutIcon" />
|
||||
<Toolbar.Item icon={List} tooltip="Bullet List" />
|
||||
<Toolbar.Item icon={ListOrdered} tooltip="Numbered List" />
|
||||
</Toolbar.Group>
|
||||
</div>
|
||||
<Toolbar.SubmitButton>Comment</Toolbar.SubmitButton>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import type { ISvgIcons } from "../icons";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { cn } from "../utils";
|
||||
|
||||
@@ -16,7 +15,7 @@ export interface ToolbarGroupProps extends React.HTMLAttributes<HTMLDivElement>
|
||||
}
|
||||
|
||||
export interface ToolbarItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon: LucideIcon | React.FC<ISvgIcons>;
|
||||
icon: LucideIcon;
|
||||
isActive?: boolean;
|
||||
tooltip?: string;
|
||||
shortcut?: string[];
|
||||
|
||||
Generated
+665
-700
File diff suppressed because it is too large
Load Diff
+10
-8
@@ -1,17 +1,16 @@
|
||||
packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
- "!apps/api"
|
||||
- "!apps/proxy"
|
||||
- apps/*
|
||||
- packages/*
|
||||
- "!apps/api"
|
||||
- "!apps/proxy"
|
||||
|
||||
catalog:
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": 1.4.0
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": 1.1.0
|
||||
"@atlaskit/pragmatic-drag-and-drop": 1.7.4
|
||||
"@bprogress/core": ^1.3.4
|
||||
"@sentry/node": 10.24.0
|
||||
"@sentry/profiling-node": 10.24.0
|
||||
"@sentry/react-router": 10.24.0
|
||||
"@sentry/node": 10.5.0
|
||||
"@sentry/profiling-node": 10.5.0
|
||||
axios: 1.12.0
|
||||
mobx: 6.12.0
|
||||
mobx-react: 9.1.1
|
||||
@@ -19,6 +18,8 @@ catalog:
|
||||
lodash-es: 4.17.21
|
||||
"@types/lodash-es": 4.17.12
|
||||
lucide-react: 0.469.0
|
||||
next: 14.2.32
|
||||
sharp: 0.33.5
|
||||
swr: 2.2.4
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1
|
||||
@@ -33,4 +34,5 @@ catalog:
|
||||
"@tiptap/html": ^2.22.3
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- turbo
|
||||
- turbo
|
||||
- sharp
|
||||
|
||||
+18
-18
@@ -2,29 +2,29 @@
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalEnv": [
|
||||
"NODE_ENV",
|
||||
"VITE_API_BASE_URL",
|
||||
"VITE_ADMIN_BASE_URL",
|
||||
"VITE_ADMIN_BASE_PATH",
|
||||
"VITE_SPACE_BASE_URL",
|
||||
"VITE_SPACE_BASE_PATH",
|
||||
"VITE_WEB_BASE_URL",
|
||||
"VITE_LIVE_BASE_URL",
|
||||
"VITE_PLAUSIBLE_DOMAIN",
|
||||
"VITE_CRISP_ID",
|
||||
"VITE_ENABLE_SESSION_RECORDER",
|
||||
"VITE_SESSION_RECORDER_KEY",
|
||||
"VITE_EXTRA_IMAGE_DOMAINS",
|
||||
"VITE_POSTHOG_KEY",
|
||||
"VITE_POSTHOG_HOST",
|
||||
"VITE_POSTHOG_DEBUG",
|
||||
"VITE_SUPPORT_EMAIL",
|
||||
"NEXT_PUBLIC_API_BASE_URL",
|
||||
"NEXT_PUBLIC_ADMIN_BASE_URL",
|
||||
"NEXT_PUBLIC_ADMIN_BASE_PATH",
|
||||
"NEXT_PUBLIC_SPACE_BASE_URL",
|
||||
"NEXT_PUBLIC_SPACE_BASE_PATH",
|
||||
"NEXT_PUBLIC_WEB_BASE_URL",
|
||||
"NEXT_PUBLIC_LIVE_BASE_URL",
|
||||
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
||||
"NEXT_PUBLIC_CRISP_ID",
|
||||
"NEXT_PUBLIC_ENABLE_SESSION_RECORDER",
|
||||
"NEXT_PUBLIC_SESSION_RECORDER_KEY",
|
||||
"NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS",
|
||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||
"NEXT_PUBLIC_POSTHOG_HOST",
|
||||
"NEXT_PUBLIC_POSTHOG_DEBUG",
|
||||
"NEXT_PUBLIC_SUPPORT_EMAIL",
|
||||
"ENABLE_EXPERIMENTAL_COREPACK"
|
||||
],
|
||||
"globalDependencies": ["pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", "build/**"]
|
||||
"outputs": [".next/**", "dist/**", "build/**"]
|
||||
},
|
||||
"build-storybook": {
|
||||
"dependsOn": ["^build"],
|
||||
@@ -41,7 +41,7 @@
|
||||
},
|
||||
"check:lint": {
|
||||
"cache": false,
|
||||
"inputs": ["**/*", "!**/build/**", "!**/dist/**"]
|
||||
"inputs": ["**/*", "!**/.next/**", "!**/dist/**"]
|
||||
},
|
||||
"check:format": {
|
||||
"cache": false
|
||||
|
||||
Reference in New Issue
Block a user