Merge pull request #4688 from makeplane/preview

release: v0.21-dev
This commit is contained in:
sriram veeraghanta 2024-06-03 18:54:06 +05:30 committed by GitHub
commit c76af7d7d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
350 changed files with 7176 additions and 2778 deletions

91
.github/workflows/build-aio-base.yml vendored Normal file
View File

@ -0,0 +1,91 @@
name: Build AIO Base Image
on:
workflow_dispatch:
env:
TARGET_BRANCH: ${{ github.ref_name }}
jobs:
base_build_setup:
name: Build Preparation
runs-on: ubuntu-latest
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
build_base: ${{ steps.changed_files.outputs.base_any_changed }}
steps:
- id: set_env_variables
name: Set Environment Variables
run: |
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- name: Get changed files
id: changed_files
uses: tj-actions/changed-files@v42
with:
files_yaml: |
base:
- aio/Dockerfile.base
base_build_push:
if: ${{ needs.base_build_setup.outputs.build_base == 'true' || github.event_name == 'workflow_dispatch' || needs.base_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-latest
needs: [base_build_setup]
env:
BASE_IMG_TAG: makeplane/plane-aio-base:${{ needs.base_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.base_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.base_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set Docker Tag
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-aio-base:latest
else
TAG=${{ env.BASE_IMG_TAG }}
fi
echo "BASE_IMG_TAG=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v5.1.0
with:
context: ./aio
file: ./aio/Dockerfile.base
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.BASE_IMG_TAG }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}

View File

@ -14,7 +14,7 @@ env:
jobs: jobs:
branch_build_setup: branch_build_setup:
name: Build-Push Web/Space/API/Proxy Docker Image name: Build Setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
@ -85,7 +85,7 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }} FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@ -95,9 +95,9 @@ jobs:
- name: Set Frontend Docker Tag - name: Set Frontend Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} TAG=makeplane/plane-frontend:stable,makeplane/plane-frontend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest TAG=makeplane/plane-frontend:latest
else else
TAG=${{ env.FRONTEND_TAG }} TAG=${{ env.FRONTEND_TAG }}
fi fi
@ -137,7 +137,7 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
ADMIN_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }} ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@ -147,9 +147,9 @@ jobs:
- name: Set Admin Docker Tag - name: Set Admin Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:${{ github.event.release.tag_name }} TAG=makeplane/plane-admin:stable,makeplane/plane-admin:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-admin:latest TAG=makeplane/plane-admin:latest
else else
TAG=${{ env.ADMIN_TAG }} TAG=${{ env.ADMIN_TAG }}
fi fi
@ -189,7 +189,7 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }} SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@ -199,9 +199,9 @@ jobs:
- name: Set Space Docker Tag - name: Set Space Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} TAG=makeplane/plane-space:stable,makeplane/plane-space:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest TAG=makeplane/plane-space:latest
else else
TAG=${{ env.SPACE_TAG }} TAG=${{ env.SPACE_TAG }}
fi fi
@ -241,7 +241,7 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }} BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@ -251,9 +251,9 @@ jobs:
- name: Set Backend Docker Tag - name: Set Backend Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} TAG=makeplane/plane-backend:stable,makeplane/plane-backend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest TAG=makeplane/plane-backend:latest
else else
TAG=${{ env.BACKEND_TAG }} TAG=${{ env.BACKEND_TAG }}
fi fi
@ -293,7 +293,7 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }} PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@ -303,9 +303,9 @@ jobs:
- name: Set Proxy Docker Tag - name: Set Proxy Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} TAG=makeplane/plane-proxy:stable,makeplane/plane-proxy:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest TAG=makeplane/plane-proxy:latest
else else
TAG=${{ env.PROXY_TAG }} TAG=${{ env.PROXY_TAG }}
fi fi

View File

@ -1,124 +0,0 @@
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo
RUN apk add tree
COPY . .
RUN turbo prune --scope=app --scope=plane-deploy --docker
CMD tree -I node_modules/
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install
# # Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL}
FROM python:3.11.1-alpine3.17 AS backend
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /code
RUN apk --no-cache add \
"libpq~=15" \
"libxslt~=1.1" \
"nodejs-current~=19" \
"xmlsec~=1.2" \
"nginx" \
"nodejs" \
"npm" \
"supervisor"
COPY apiserver/requirements.txt ./
COPY apiserver/requirements ./requirements
RUN apk add --no-cache libffi-dev
RUN apk add --no-cache --virtual .build-deps \
"bash~=5.2" \
"g++~=12.2" \
"gcc~=12.2" \
"cargo~=1.64" \
"git~=2" \
"make~=4.3" \
"postgresql13-dev~=13" \
"libc-dev" \
"linux-headers" \
&& \
pip install -r requirements.txt --compile --no-cache-dir \
&& \
apk del .build-deps
# Add in Django deps and generate Django's static files
COPY apiserver/manage.py manage.py
COPY apiserver/plane plane/
COPY apiserver/templates templates/
RUN apk --no-cache add "bash~=5.2"
COPY apiserver/bin ./bin/
RUN chmod +x ./bin/*
RUN chmod -R 777 /code
# Expose container port and run entry point script
WORKDIR /app
COPY --from=installer /app/apps/app/next.config.js .
COPY --from=installer /app/apps/app/package.json .
COPY --from=installer /app/apps/space/next.config.js .
COPY --from=installer /app/apps/space/package.json .
COPY --from=installer /app/apps/app/.next/standalone ./
COPY --from=installer /app/apps/app/.next/static ./apps/app/.next/static
COPY --from=installer /app/apps/space/.next/standalone ./
COPY --from=installer /app/apps/space/.next ./apps/space/.next
ENV NEXT_TELEMETRY_DISABLED 1
# RUN rm /etc/nginx/conf.d/default.conf
#######################################################################
COPY nginx/nginx-single-docker-image.conf /etc/nginx/http.d/default.conf
#######################################################################
COPY nginx/supervisor.conf /code/supervisor.conf
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh
EXPOSE 80
CMD ["supervisord","-c","/code/supervisor.conf"]

View File

@ -19,14 +19,14 @@ const InstanceAIPage = observer(() => {
return ( return (
<> <>
<PageHeader title="Artificial Intelligence - God Mode" /> <PageHeader title="Artificial Intelligence - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">AI features for all your workspaces</div> <div className="text-xl font-medium text-custom-text-100">AI features for all your workspaces</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Configure your AI API credentials so Plane AI features are turned on for all your workspaces. Configure your AI API credentials so Plane AI features are turned on for all your workspaces.
</div> </div>
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? ( {formattedConfig ? (
<InstanceAIForm config={formattedConfig} /> <InstanceAIForm config={formattedConfig} />
) : ( ) : (

View File

@ -64,8 +64,8 @@ const InstanceGithubAuthenticationPage = observer(() => {
return ( return (
<> <>
<PageHeader title="Authentication - God Mode" /> <PageHeader title="Authentication - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard <AuthenticationMethodCard
name="Github" name="Github"
description="Allow members to login or sign up to plane with their Github accounts." description="Allow members to login or sign up to plane with their Github accounts."
@ -93,7 +93,7 @@ const InstanceGithubAuthenticationPage = observer(() => {
withBorder={false} withBorder={false}
/> />
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md p-4">
{formattedConfig ? ( {formattedConfig ? (
<InstanceGithubConfigForm config={formattedConfig} /> <InstanceGithubConfigForm config={formattedConfig} />
) : ( ) : (

View File

@ -58,8 +58,8 @@ const InstanceGoogleAuthenticationPage = observer(() => {
return ( return (
<> <>
<PageHeader title="Authentication - God Mode" /> <PageHeader title="Authentication - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard <AuthenticationMethodCard
name="Google" name="Google"
description="Allow members to login or sign up to plane with their Google description="Allow members to login or sign up to plane with their Google
@ -81,7 +81,7 @@ const InstanceGoogleAuthenticationPage = observer(() => {
withBorder={false} withBorder={false}
/> />
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md p-4">
{formattedConfig ? ( {formattedConfig ? (
<InstanceGoogleConfigForm config={formattedConfig} /> <InstanceGoogleConfigForm config={formattedConfig} />
) : ( ) : (

View File

@ -119,14 +119,14 @@ const InstanceAuthenticationPage = observer(() => {
return ( return (
<> <>
<PageHeader title="Authentication - God Mode" /> <PageHeader title="Authentication - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Manage authentication for your instance</div> <div className="text-xl font-medium text-custom-text-100">Manage authentication for your instance</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Configure authentication modes for your team and restrict sign ups to be invite only. Configure authentication modes for your team and restrict sign ups to be invite only.
</div> </div>
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? ( {formattedConfig ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-lg font-medium">Authentication modes</div> <div className="text-lg font-medium">Authentication modes</div>

View File

@ -19,8 +19,8 @@ const InstanceEmailPage = observer(() => {
return ( return (
<> <>
<PageHeader title="Email - God Mode" /> <PageHeader title="Email - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Secure emails from your own instance</div> <div className="text-xl font-medium text-custom-text-100">Secure emails from your own instance</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Plane can send useful emails to you and your users from your own instance without talking to the Internet. Plane can send useful emails to you and your users from your own instance without talking to the Internet.
@ -30,7 +30,7 @@ const InstanceEmailPage = observer(() => {
</div> </div>
</div> </div>
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? ( {formattedConfig ? (
<InstanceEmailForm config={formattedConfig} /> <InstanceEmailForm config={formattedConfig} />
) : ( ) : (

View File

@ -51,7 +51,7 @@ export const SendTestEmailModal: FC<Props> = (props) => {
setSendEmailStep(ESendEmailSteps.SUCCESS); setSendEmailStep(ESendEmailSteps.SUCCESS);
}) })
.catch((error) => { .catch((error) => {
setError(error?.message || "Failed to send email"); setError(error?.error || "Failed to send email");
setSendEmailStep(ESendEmailSteps.FAILED); setSendEmailStep(ESendEmailSteps.FAILED);
}) })
.finally(() => { .finally(() => {

View File

@ -10,15 +10,15 @@ function GeneralPage() {
console.log("instance", instance); console.log("instance", instance);
return ( return (
<> <>
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">General settings</div> <div className="text-xl font-medium text-custom-text-100">General settings</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your
instance. instance.
</div> </div>
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{instance && instanceAdmins && ( {instance && instanceAdmins && (
<GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} /> <GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} />
)} )}

View File

@ -332,42 +332,90 @@ body {
} }
/* scrollbar style */ /* scrollbar style */
::-webkit-scrollbar { @-moz-document url-prefix() {
display: none; * {
scrollbar-width: none;
}
.vertical-scrollbar,
.horizontal-scrollbar {
scrollbar-width: initial;
scrollbar-color: rgba(96, 100, 108, 0.1) transparent;
}
.vertical-scrollbar:hover,
.horizontal-scrollbar:hover {
scrollbar-color: rgba(96, 100, 108, 0.25) transparent;
}
.vertical-scrollbar:active,
.horizontal-scrollbar:active {
scrollbar-color: rgba(96, 100, 108, 0.7) transparent;
}
} }
.horizontal-scroll-enable { .vertical-scrollbar {
overflow-x: scroll; overflow-y: auto;
} }
.horizontal-scrollbar {
.horizontal-scroll-enable::-webkit-scrollbar { overflow-x: auto;
}
.vertical-scrollbar::-webkit-scrollbar,
.horizontal-scrollbar::-webkit-scrollbar {
display: block; display: block;
height: 7px; }
width: 0; .vertical-scrollbar::-webkit-scrollbar-track,
.horizontal-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
border-radius: 9999px;
}
.vertical-scrollbar::-webkit-scrollbar-thumb,
.horizontal-scrollbar::-webkit-scrollbar-thumb {
background-clip: padding-box;
background-color: rgba(96, 100, 108, 0.1);
border-radius: 9999px;
}
.vertical-scrollbar:hover::-webkit-scrollbar-thumb,
.horizontal-scrollbar:hover::-webkit-scrollbar-thumb {
background-color: rgba(96, 100, 108, 0.25);
}
.vertical-scrollbar::-webkit-scrollbar-thumb:hover,
.horizontal-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(96, 100, 108, 0.5);
}
.vertical-scrollbar::-webkit-scrollbar-thumb:active,
.horizontal-scrollbar::-webkit-scrollbar-thumb:active {
background-color: rgba(96, 100, 108, 0.7);
}
.vertical-scrollbar::-webkit-scrollbar-corner,
.horizontal-scrollbar::-webkit-scrollbar-corner {
background-color: transparent;
}
.vertical-scrollbar-margin-top-md::-webkit-scrollbar-track {
margin-top: 44px;
} }
.horizontal-scroll-enable::-webkit-scrollbar-track { /* scrollbar sm size */
height: 7px; .scrollbar-sm::-webkit-scrollbar {
background-color: rgba(var(--color-background-100)); height: 12px;
width: 12px;
} }
.scrollbar-sm::-webkit-scrollbar-thumb {
.horizontal-scroll-enable::-webkit-scrollbar-thumb { border: 3px solid rgba(0, 0, 0, 0);
border-radius: 5px;
background-color: rgba(var(--color-scrollbar));
} }
/* scrollbar md size */
.vertical-scroll-enable::-webkit-scrollbar { .scrollbar-md::-webkit-scrollbar {
display: block; height: 14px;
width: 5px; width: 14px;
} }
.scrollbar-md::-webkit-scrollbar-thumb {
.vertical-scroll-enable::-webkit-scrollbar-track { border: 3px solid rgba(0, 0, 0, 0);
width: 5px;
} }
/* scrollbar lg size */
.vertical-scroll-enable::-webkit-scrollbar-thumb { .scrollbar-lg::-webkit-scrollbar {
border-radius: 5px; height: 16px;
background-color: rgba(var(--color-background-90)); width: 16px;
}
.scrollbar-lg::-webkit-scrollbar-thumb {
border: 4px solid rgba(0, 0, 0, 0);
} }
/* end scrollbar style */ /* end scrollbar style */

View File

@ -19,14 +19,14 @@ const InstanceImagePage = observer(() => {
return ( return (
<> <>
<PageHeader title="Image - God Mode" /> <PageHeader title="Image - God Mode" />
<div className="relative container mx-auto w-full h-full p-8 py-4 space-y-6 flex flex-col"> <div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 pb-3 space-y-1 flex-shrink-0"> <div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Third-party image libraries</div> <div className="text-xl font-medium text-custom-text-100">Third-party image libraries</div>
<div className="text-sm font-normal text-custom-text-300"> <div className="text-sm font-normal text-custom-text-300">
Let your users search and choose images from third-party libraries Let your users search and choose images from third-party libraries
</div> </div>
</div> </div>
<div className="flex-grow overflow-hidden overflow-y-auto"> <div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? ( {formattedConfig ? (
<InstanceImageConfigForm config={formattedConfig} /> <InstanceImageConfigForm config={formattedConfig} />
) : ( ) : (

View File

@ -38,7 +38,7 @@ export const HelpSection: FC = observer(() => {
// refs // refs
const helpOptionsRef = useRef<HTMLDivElement | null>(null); const helpOptionsRef = useRef<HTMLDivElement | null>(null);
const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace"); const redirectionLink = encodeURI(WEB_BASE_URL + "/");
return ( return (
<div <div

View File

@ -56,7 +56,7 @@ export const SidebarMenu = observer(() => {
}; };
return ( return (
<div className="flex h-full w-full flex-col gap-2.5 overflow-y-auto px-4 py-4"> <div className="flex h-full w-full flex-col gap-2.5 overflow-y-scroll vertical-scrollbar scrollbar-sm px-4 py-4">
{INSTANCE_ADMIN_LINKS.map((item, index) => { {INSTANCE_ADMIN_LINKS.map((item, index) => {
const isActive = item.href === pathName || pathName.includes(item.href); const isActive = item.href === pathName || pathName.includes(item.href);
return ( return (

View File

@ -24,7 +24,7 @@ export const CopyField: React.FC<Props> = (props) => {
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-sm text-custom-text-300">{label}</h4> <h4 className="text-sm text-custom-text-200">{label}</h4>
<Button <Button
variant="neutral-primary" variant="neutral-primary"
className="flex items-center justify-between py-2" className="flex items-center justify-between py-2"
@ -40,7 +40,7 @@ export const CopyField: React.FC<Props> = (props) => {
<p className="text-sm font-medium">{url}</p> <p className="text-sm font-medium">{url}</p>
<Copy size={18} color="#B9B9B9" /> <Copy size={18} color="#B9B9B9" />
</Button> </Button>
<div className="text-xs text-custom-text-400">{description}</div> <div className="text-xs text-custom-text-300">{description}</div>
</div> </div>
); );
}; };

View File

@ -158,6 +158,7 @@ export const InstanceSetupForm: FC = (props) => {
onError={() => setIsSubmitting(false)} onError={() => setIsSubmitting(false)}
> >
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} /> <input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<input type="hidden" name="is_telemetry_enabled" value={formData.is_telemetry_enabled ? "True" : "False"} />
<div className="flex flex-col sm:flex-row items-center gap-4"> <div className="flex flex-col sm:flex-row items-center gap-4">
<div className="w-full space-y-1"> <div className="w-full space-y-1">
@ -319,8 +320,6 @@ export const InstanceSetupForm: FC = (props) => {
<div> <div>
<Checkbox <Checkbox
id="is_telemetry_enabled" id="is_telemetry_enabled"
name="is_telemetry_enabled"
value={formData.is_telemetry_enabled ? "True" : "False"}
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
checked={formData.is_telemetry_enabled} checked={formData.is_telemetry_enabled}
/> />

View File

@ -7,9 +7,9 @@ import { useTheme as nextUseTheme } from "next-themes";
// ui // ui
import { Button, getButtonStyling } from "@plane/ui"; import { Button, getButtonStyling } from "@plane/ui";
// helpers // helpers
import { resolveGeneralTheme } from "helpers/common.helper"; import { WEB_BASE_URL, resolveGeneralTheme } from "helpers/common.helper";
// hooks // hooks
import { useInstance, useTheme } from "@/hooks/store"; import { useTheme } from "@/hooks/store";
// icons // icons
import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg"; import TakeoffIconLight from "/public/logos/takeoff-icon-light.svg";
import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg"; import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg";
@ -17,11 +17,10 @@ import TakeoffIconDark from "/public/logos/takeoff-icon-dark.svg";
export const NewUserPopup: React.FC = observer(() => { export const NewUserPopup: React.FC = observer(() => {
// hooks // hooks
const { isNewUserPopup, toggleNewUserPopup } = useTheme(); const { isNewUserPopup, toggleNewUserPopup } = useTheme();
const { config } = useInstance();
// theme // theme
const { resolvedTheme } = nextUseTheme(); const { resolvedTheme } = nextUseTheme();
const redirectionLink = `${config?.app_base_url ? `${config?.app_base_url}/create-workspace` : `/god-mode/`}`; const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace");
if (!isNewUserPopup) return <></>; if (!isNewUserPopup) return <></>;
return ( return (

View File

@ -1,4 +1,5 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
trailingSlash: true, trailingSlash: true,
reactStrictMode: false, reactStrictMode: false,

View File

@ -1,6 +1,6 @@
{ {
"name": "admin", "name": "admin",
"version": "0.20.0", "version": "0.21.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo run develop", "dev": "turbo run develop",

149
aio/Dockerfile Normal file
View File

@ -0,0 +1,149 @@
# *****************************************************************************
# STAGE 1: Build the project
# *****************************************************************************
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=web --scope=space --scope=admin --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
# *****************************************************************************
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install
# # Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
ENV NEXT_TELEMETRY_DISABLED 1
ENV TURBO_TELEMETRY_DISABLED 1
RUN yarn turbo run build
# *****************************************************************************
# STAGE 3: Copy the project and start it
# *****************************************************************************
# FROM makeplane/plane-aio-base AS runner
FROM makeplane/plane-aio-base:develop AS runner
WORKDIR /app
SHELL [ "/bin/bash", "-c" ]
# PYTHON APPLICATION SETUP
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
COPY apiserver/requirements.txt ./api/
COPY apiserver/requirements ./api/requirements
RUN python3.12 -m venv /app/venv && \
source /app/venv/bin/activate && \
/app/venv/bin/pip install --upgrade pip && \
/app/venv/bin/pip install -r ./api/requirements.txt --compile --no-cache-dir
# Add in Django deps and generate Django's static files
COPY apiserver/manage.py ./api/manage.py
COPY apiserver/plane ./api/plane/
COPY apiserver/templates ./api/templates/
COPY package.json ./api/package.json
COPY apiserver/bin ./api/bin/
RUN chmod +x ./api/bin/*
RUN chmod -R 777 ./api/
# NEXTJS BUILDS
COPY --from=installer /app/web/next.config.js ./web/
COPY --from=installer /app/web/package.json ./web/
COPY --from=installer /app/web/.next/standalone ./web
COPY --from=installer /app/web/.next/static ./web/web/.next/static
COPY --from=installer /app/web/public ./web/web/public
COPY --from=installer /app/space/next.config.js ./space/
COPY --from=installer /app/space/package.json ./space/
COPY --from=installer /app/space/.next/standalone ./space
COPY --from=installer /app/space/.next/static ./space/space/.next/static
COPY --from=installer /app/space/public ./space/space/public
COPY --from=installer /app/admin/next.config.js ./admin/
COPY --from=installer /app/admin/package.json ./admin/
COPY --from=installer /app/admin/.next/standalone ./admin
COPY --from=installer /app/admin/.next/static ./admin/admin/.next/static
COPY --from=installer /app/admin/public ./admin/admin/public
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
ARG NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED 1
ENV TURBO_TELEMETRY_DISABLED 1
COPY aio/supervisord.conf /app/supervisord.conf
COPY aio/aio.sh /app/aio.sh
RUN chmod +x /app/aio.sh
COPY aio/pg-setup.sh /app/pg-setup.sh
RUN chmod +x /app/pg-setup.sh
COPY deploy/selfhost/variables.env /app/plane.env
# NGINX Conf Copy
COPY ./aio/nginx.conf.aio /etc/nginx/nginx.conf.template
COPY ./nginx/env.sh /app/nginx-start.sh
RUN chmod +x /app/nginx-start.sh
RUN ./pg-setup.sh
VOLUME [ "/app/data/minio/uploads", "/var/lib/postgresql/data" ]
CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"]

92
aio/Dockerfile.base Normal file
View File

@ -0,0 +1,92 @@
FROM --platform=$BUILDPLATFORM tonistiigi/binfmt as binfmt
FROM debian:12-slim
# Set environment variables to non-interactive for apt
ENV DEBIAN_FRONTEND=noninteractive
SHELL [ "/bin/bash", "-c" ]
# Update the package list and install prerequisites
RUN apt-get update && \
apt-get install -y \
gnupg2 curl ca-certificates lsb-release software-properties-common \
build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev xz-utils \
tk-dev libffi-dev liblzma-dev supervisor nginx nano vim ncdu
# Install Redis 7.2
RUN echo "deb http://deb.debian.org/debian $(lsb_release -cs)-backports main" > /etc/apt/sources.list.d/backports.list && \
curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" > /etc/apt/sources.list.d/redis.list && \
apt-get update && \
apt-get install -y redis-server
# Install PostgreSQL 15
ENV POSTGRES_VERSION 15
RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg-archive-keyring.gpg && \
echo "deb [signed-by=/usr/share/keyrings/pgdg-archive-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
apt-get update && \
apt-get install -y postgresql-$POSTGRES_VERSION postgresql-client-$POSTGRES_VERSION && \
mkdir -p /var/lib/postgresql/data && \
chown -R postgres:postgres /var/lib/postgresql
# Install MinIO
ARG TARGETARCH
RUN if [ "$TARGETARCH" = "amd64" ]; then \
curl -fSl https://dl.min.io/server/minio/release/linux-amd64/minio -o /usr/local/bin/minio; \
elif [ "$TARGETARCH" = "arm64" ]; then \
curl -fSl https://dl.min.io/server/minio/release/linux-arm64/minio -o /usr/local/bin/minio; \
else \
echo "Unsupported architecture: $TARGETARCH"; exit 1; \
fi && \
chmod +x /usr/local/bin/minio
# Install Node.js 18
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
apt-get install -y nodejs
# Install Python 3.12 from source
RUN cd /usr/src && \
wget https://www.python.org/ftp/python/3.12.0/Python-3.12.0.tgz && \
tar xzf Python-3.12.0.tgz && \
cd Python-3.12.0 && \
./configure --enable-optimizations && \
make altinstall && \
rm -f /usr/src/Python-3.12.0.tgz
RUN python3.12 -m pip install --upgrade pip
RUN echo "alias python=/usr/local/bin/python3.12" >> ~/.bashrc && \
echo "alias pip=/usr/local/bin/pip3.12" >> ~/.bashrc
# Clean up
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/* /usr/src/Python-3.12.0
WORKDIR /app
RUN mkdir -p /app/{data,logs} && \
mkdir -p /app/data/{redis,pg,minio,nginx} && \
mkdir -p /app/logs/{access,error} && \
mkdir -p /etc/supervisor/conf.d
# Create Supervisor configuration file
COPY supervisord.base /app/supervisord.conf
RUN apt-get update && \
apt-get install -y sudo lsof net-tools libpq-dev procps gettext && \
apt-get clean
RUN sudo -u postgres /usr/lib/postgresql/$POSTGRES_VERSION/bin/initdb -D /var/lib/postgresql/data
COPY postgresql.conf /etc/postgresql/postgresql.conf
RUN echo "alias python=/usr/local/bin/python3.12" >> ~/.bashrc && \
echo "alias pip=/usr/local/bin/pip3.12" >> ~/.bashrc
# Expose ports for Redis, PostgreSQL, and MinIO
EXPOSE 6379 5432 9000 80
# Start Supervisor
CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"]

30
aio/aio.sh Normal file
View File

@ -0,0 +1,30 @@
#!/bin/bash
set -e
if [ "$1" = 'api' ]; then
source /app/venv/bin/activate
cd /app/api
exec ./bin/docker-entrypoint-api.sh
elif [ "$1" = 'worker' ]; then
source /app/venv/bin/activate
cd /app/api
exec ./bin/docker-entrypoint-worker.sh
elif [ "$1" = 'beat' ]; then
source /app/venv/bin/activate
cd /app/api
exec ./bin/docker-entrypoint-beat.sh
elif [ "$1" = 'migrator' ]; then
source /app/venv/bin/activate
cd /app/api
exec ./bin/docker-entrypoint-migrator.sh
elif [ "$1" = 'web' ]; then
node /app/web/web/server.js
elif [ "$1" = 'space' ]; then
node /app/space/space/server.js
elif [ "$1" = 'admin' ]; then
node /app/admin/admin/server.js
else
echo "Command not found"
exit 1
fi

73
aio/nginx.conf.aio Normal file
View File

@ -0,0 +1,73 @@
events {
}
http {
sendfile on;
server {
listen 80;
root /www/data/;
access_log /var/log/nginx/access.log;
client_max_body_size ${FILE_SIZE_LIMIT};
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "interest-cohort=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Forwarded-Proto "${dollar}scheme";
add_header X-Forwarded-Host "${dollar}host";
add_header X-Forwarded-For "${dollar}proxy_add_x_forwarded_for";
add_header X-Real-IP "${dollar}remote_addr";
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:3001/;
}
location /spaces/ {
rewrite ^/spaces/?$ /spaces/login break;
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:3002/spaces/;
}
location /god-mode/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:3003/god-mode/;
}
location /api/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:8000/api/;
}
location /auth/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:8000/auth/;
}
location /${BUCKET_NAME}/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:9000/uploads/;
}
}
}

14
aio/pg-setup.sh Normal file
View File

@ -0,0 +1,14 @@
#!/bin/bash
# Variables
set -o allexport
source plane.env set
set +o allexport
export PGHOST=localhost
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data start
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/psql" --command "CREATE USER $POSTGRES_USER WITH SUPERUSER PASSWORD '$POSTGRES_PASSWORD';" && \
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/createdb" -O "$POSTGRES_USER" "$POSTGRES_DB" && \
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data stop

12
aio/postgresql.conf Normal file
View File

@ -0,0 +1,12 @@
# PostgreSQL configuration file
# Allow connections from any IP address
listen_addresses = '*'
# Set the maximum number of connections
max_connections = 100
# Set the shared buffers size
shared_buffers = 128MB
# Other custom configurations can be added here

37
aio/supervisord.base Normal file
View File

@ -0,0 +1,37 @@
[supervisord]
user=root
nodaemon=true
stderr_logfile=/app/logs/error/supervisor.err.log
stdout_logfile=/app/logs/access/supervisor.out.log
[program:redis]
directory=/app/data/redis
command=redis-server
autostart=true
autorestart=true
stderr_logfile=/app/logs/error/redis.err.log
stdout_logfile=/app/logs/access/redis.out.log
[program:postgresql]
user=postgres
command=/usr/lib/postgresql/15/bin/postgres --config-file=/etc/postgresql/15/main/postgresql.conf
autostart=true
autorestart=true
stderr_logfile=/app/logs/error/postgresql.err.log
stdout_logfile=/app/logs/access/postgresql.out.log
[program:minio]
directory=/app/data/minio
command=minio server /app/data/minio
autostart=true
autorestart=true
stderr_logfile=/app/logs/error/minio.err.log
stdout_logfile=/app/logs/access/minio.out.log
[program:nginx]
directory=/app/data/nginx
command=/usr/sbin/nginx -g 'daemon off;'
autostart=true
autorestart=true
stderr_logfile=/app/logs/error/nginx.err.log
stdout_logfile=/app/logs/access/nginx.out.log

115
aio/supervisord.conf Normal file
View File

@ -0,0 +1,115 @@
[supervisord]
user=root
nodaemon=true
priority=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
[program:redis]
directory=/app/data/redis
command=redis-server
autostart=true
autorestart=true
priority=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
[program:postgresql]
user=postgres
command=/usr/lib/postgresql/15/bin/postgres -D /var/lib/postgresql/data --config-file=/etc/postgresql/postgresql.conf
autostart=true
autorestart=true
priority=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
[program:minio]
directory=/app/data/minio
command=minio server /app/data/minio
autostart=true
autorestart=true
priority=1
stdout_logfile=/app/logs/access/minio.log
stderr_logfile=/app/logs/error/minio.err.log
[program:nginx]
command=/app/nginx-start.sh
autostart=true
autorestart=true
priority=1
stdout_logfile=/app/logs/access/nginx.log
stderr_logfile=/app/logs/error/nginx.err.log
[program:web]
command=/app/aio.sh web
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
environment=PORT=3001,HOSTNAME=0.0.0.0
[program:space]
command=/app/aio.sh space
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
environment=PORT=3002,HOSTNAME=0.0.0.0
[program:admin]
command=/app/aio.sh admin
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
environment=PORT=3003,HOSTNAME=0.0.0.0
[program:migrator]
command=/app/aio.sh migrator
autostart=true
autorestart=false
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
[program:api]
command=/app/aio.sh api
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
[program:worker]
command=/app/aio.sh worker
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
[program:beat]
command=/app/aio.sh beat
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0

View File

@ -1,4 +1,4 @@
{ {
"name": "plane-api", "name": "plane-api",
"version": "0.20.0" "version": "0.21.0"
} }

View File

@ -182,7 +182,6 @@ class IssueAPIEndpoint(BaseAPIView):
issue_queryset = ( issue_queryset = (
self.get_queryset() self.get_queryset()
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by() .order_by()

View File

@ -66,6 +66,7 @@ class CycleSerializer(BaseSerializer):
"external_source", "external_source",
"external_id", "external_id",
"progress_snapshot", "progress_snapshot",
"logo_props",
# meta fields # meta fields
"is_favorite", "is_favorite",
"total_issues", "total_issues",

View File

@ -199,6 +199,7 @@ class ModuleSerializer(DynamicBaseSerializer):
"sort_order", "sort_order",
"external_source", "external_source",
"external_id", "external_id",
"logo_props",
# computed fields # computed fields
"is_favorite", "is_favorite",
"total_issues", "total_issues",

View File

@ -39,6 +39,7 @@ class PageSerializer(BaseSerializer):
"created_by", "created_by",
"updated_by", "updated_by",
"view_props", "view_props",
"logo_props",
] ]
read_only_fields = [ read_only_fields = [
"workspace", "workspace",
@ -106,7 +107,9 @@ class PageDetailSerializer(PageSerializer):
description_html = serializers.CharField() description_html = serializers.CharField()
class Meta(PageSerializer.Meta): class Meta(PageSerializer.Meta):
fields = PageSerializer.Meta.fields + ["description_html"] fields = PageSerializer.Meta.fields + [
"description_html",
]
class SubPageSerializer(BaseSerializer): class SubPageSerializer(BaseSerializer):

View File

@ -6,6 +6,7 @@ from plane.app.views import (
PageFavoriteViewSet, PageFavoriteViewSet,
PageLogEndpoint, PageLogEndpoint,
SubPagesEndpoint, SubPagesEndpoint,
PagesDescriptionViewSet,
) )
@ -79,4 +80,14 @@ urlpatterns = [
SubPagesEndpoint.as_view(), SubPagesEndpoint.as_view(),
name="sub-page", name="sub-page",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/description/",
PagesDescriptionViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
}
),
name="page-description",
),
] ]

View File

@ -177,6 +177,7 @@ from .page.base import (
PageFavoriteViewSet, PageFavoriteViewSet,
PageLogEndpoint, PageLogEndpoint,
SubPagesEndpoint, SubPagesEndpoint,
PagesDescriptionViewSet,
) )
from .search import GlobalSearchEndpoint, IssueSearchEndpoint from .search import GlobalSearchEndpoint, IssueSearchEndpoint

View File

@ -231,6 +231,7 @@ class CycleViewSet(BaseViewSet):
"external_source", "external_source",
"external_id", "external_id",
"progress_snapshot", "progress_snapshot",
"logo_props",
# meta fields # meta fields
"is_favorite", "is_favorite",
"total_issues", "total_issues",
@ -356,6 +357,7 @@ class CycleViewSet(BaseViewSet):
"external_source", "external_source",
"external_id", "external_id",
"progress_snapshot", "progress_snapshot",
"logo_props",
# meta fields # meta fields
"is_favorite", "is_favorite",
"total_issues", "total_issues",
@ -403,6 +405,7 @@ class CycleViewSet(BaseViewSet):
"external_source", "external_source",
"external_id", "external_id",
"progress_snapshot", "progress_snapshot",
"logo_props",
# meta fields # meta fields
"is_favorite", "is_favorite",
"cancelled_issues", "cancelled_issues",
@ -496,6 +499,7 @@ class CycleViewSet(BaseViewSet):
"external_source", "external_source",
"external_id", "external_id",
"progress_snapshot", "progress_snapshot",
"logo_props",
# meta fields # meta fields
"is_favorite", "is_favorite",
"total_issues", "total_issues",
@ -556,6 +560,7 @@ class CycleViewSet(BaseViewSet):
"external_id", "external_id",
"progress_snapshot", "progress_snapshot",
"sub_issues", "sub_issues",
"logo_props",
# meta fields # meta fields
"is_favorite", "is_favorite",
"total_issues", "total_issues",

View File

@ -225,6 +225,7 @@ class ModuleViewSet(BaseViewSet):
"sort_order", "sort_order",
"external_source", "external_source",
"external_id", "external_id",
"logo_props",
# computed fields # computed fields
"is_favorite", "is_favorite",
"cancelled_issues", "cancelled_issues",
@ -281,6 +282,7 @@ class ModuleViewSet(BaseViewSet):
"sort_order", "sort_order",
"external_source", "external_source",
"external_id", "external_id",
"logo_props",
# computed fields # computed fields
"total_issues", "total_issues",
"is_favorite", "is_favorite",
@ -465,6 +467,7 @@ class ModuleViewSet(BaseViewSet):
"sort_order", "sort_order",
"external_source", "external_source",
"external_id", "external_id",
"logo_props",
# computed fields # computed fields
"is_favorite", "is_favorite",
"cancelled_issues", "cancelled_issues",

View File

@ -1,5 +1,6 @@
# Python imports # Python imports
import json import json
import base64
from datetime import datetime from datetime import datetime
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
@ -8,6 +9,7 @@ from django.db import connection
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.http import StreamingHttpResponse
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -388,3 +390,48 @@ class SubPagesEndpoint(BaseAPIView):
return Response( return Response(
SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK
) )
class PagesDescriptionViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
def retrieve(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
binary_data = page.description_binary
def stream_data():
if binary_data:
yield binary_data
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="page_description.bin"'
)
return response
def partial_update(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
base64_data = request.data.get("description_binary")
if base64_data:
# Decode the base64 data to bytes
new_binary_data = base64.b64decode(base64_data)
# Store the updated binary data
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.save()
return Response({"message": "Updated successfully"})
else:
return Response({"error": "No binary data provided"})

View File

@ -1,5 +1,5 @@
# Python imports # Python imports
# import uuid import uuid
# Django imports # Django imports
from django.db.models import Case, Count, IntegerField, Q, When from django.db.models import Case, Count, IntegerField, Q, When
@ -183,8 +183,8 @@ class UserEndpoint(BaseViewSet):
profile.save() profile.save()
# Reset password # Reset password
# user.is_password_autoset = True user.is_password_autoset = True
# user.set_password(uuid.uuid4().hex) user.set_password(uuid.uuid4().hex)
# Deactivate the user # Deactivate the user
user.is_active = False user.is_active = False

View File

@ -17,6 +17,7 @@ AUTHENTICATION_ERROR_CODES = {
"INVALID_EMAIL_SIGN_UP": 5045, "INVALID_EMAIL_SIGN_UP": 5045,
"INVALID_EMAIL_MAGIC_SIGN_UP": 5050, "INVALID_EMAIL_MAGIC_SIGN_UP": 5050,
"MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5055, "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED": 5055,
"EMAIL_PASSWORD_AUTHENTICATION_DISABLED": 5056,
# Sign In # Sign In
"USER_DOES_NOT_EXIST": 5060, "USER_DOES_NOT_EXIST": 5060,
"AUTHENTICATION_FAILED_SIGN_IN": 5065, "AUTHENTICATION_FAILED_SIGN_IN": 5065,

View File

@ -8,6 +8,10 @@ from django.utils import timezone
from plane.db.models import Account from plane.db.models import Account
from .base import Adapter from .base import Adapter
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class OauthAdapter(Adapter): class OauthAdapter(Adapter):
@ -50,20 +54,42 @@ class OauthAdapter(Adapter):
return self.complete_login_or_signup() return self.complete_login_or_signup()
def get_user_token(self, data, headers=None): def get_user_token(self, data, headers=None):
headers = headers or {} try:
response = requests.post( headers = headers or {}
self.get_token_url(), data=data, headers=headers response = requests.post(
) self.get_token_url(), data=data, headers=headers
response.raise_for_status() )
return response.json() response.raise_for_status()
return response.json()
except requests.RequestException:
code = (
"GOOGLE_OAUTH_PROVIDER_ERROR"
if self.provider == "google"
else "GITHUB_OAUTH_PROVIDER_ERROR"
)
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[code],
error_message=str(code),
)
def get_user_response(self): def get_user_response(self):
headers = { try:
"Authorization": f"Bearer {self.token_data.get('access_token')}" headers = {
} "Authorization": f"Bearer {self.token_data.get('access_token')}"
response = requests.get(self.get_user_info_url(), headers=headers) }
response.raise_for_status() response = requests.get(self.get_user_info_url(), headers=headers)
return response.json() response.raise_for_status()
return response.json()
except requests.RequestException:
code = (
"GOOGLE_OAUTH_PROVIDER_ERROR"
if self.provider == "google"
else "GITHUB_OAUTH_PROVIDER_ERROR"
)
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[code],
error_message=str(code),
)
def set_user_data(self, data): def set_user_data(self, data):
self.user_data = data self.user_data = data

View File

@ -41,8 +41,10 @@ class EmailProvider(CredentialAdapter):
if ENABLE_EMAIL_PASSWORD == "0": if ENABLE_EMAIL_PASSWORD == "0":
raise AuthenticationException( raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ENABLE_EMAIL_PASSWORD"], error_code=AUTHENTICATION_ERROR_CODES[
error_message="ENABLE_EMAIL_PASSWORD", "EMAIL_PASSWORD_AUTHENTICATION_DISABLED"
],
error_message="EMAIL_PASSWORD_AUTHENTICATION_DISABLED",
) )
def set_user_data(self): def set_user_data(self):

View File

@ -105,14 +105,26 @@ class GitHubOAuthProvider(OauthAdapter):
) )
def __get_email(self, headers): def __get_email(self, headers):
# Github does not provide email in user response try:
emails_url = "https://api.github.com/user/emails" # Github does not provide email in user response
emails_response = requests.get(emails_url, headers=headers).json() emails_url = "https://api.github.com/user/emails"
email = next( emails_response = requests.get(emails_url, headers=headers).json()
(email["email"] for email in emails_response if email["primary"]), email = next(
None, (
) email["email"]
return email for email in emails_response
if email["primary"]
),
None,
)
return email
except requests.RequestException:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[
"GITHUB_OAUTH_PROVIDER_ERROR"
],
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
def set_user_data(self): def set_user_data(self):
user_info_response = self.get_user_response() user_info_response = self.get_user_response()

View File

@ -0,0 +1,34 @@
# Django imports
from django.core.management import BaseCommand, CommandError
# Module imports
from plane.db.models import User
class Command(BaseCommand):
help = "Make the user with the given email active"
def add_arguments(self, parser):
# Positional argument
parser.add_argument("email", type=str, help="user email")
def handle(self, *args, **options):
# get the user email from console
email = options.get("email", False)
# raise error if email is not present
if not email:
raise CommandError("Error: Email is required")
# filter the user
user = User.objects.filter(email=email).first()
# Raise error if the user is not present
if not user:
raise CommandError(f"Error: User with {email} does not exists")
# Activate the user
user.is_active = True
user.save()
self.stdout.write(self.style.SUCCESS("User activated succesfully"))

View File

@ -18,6 +18,7 @@ def get_view_props():
class Page(ProjectBaseModel): class Page(ProjectBaseModel):
name = models.CharField(max_length=255, blank=True) name = models.CharField(max_length=255, blank=True)
description = models.JSONField(default=dict, blank=True) description = models.JSONField(default=dict, blank=True)
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>") description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True) description_stripped = models.TextField(blank=True, null=True)
owned_by = models.ForeignKey( owned_by = models.ForeignKey(
@ -43,7 +44,6 @@ class Page(ProjectBaseModel):
is_locked = models.BooleanField(default=False) is_locked = models.BooleanField(default=False)
view_props = models.JSONField(default=get_view_props) view_props = models.JSONField(default=get_view_props)
logo_props = models.JSONField(default=dict) logo_props = models.JSONField(default=dict)
description_binary = models.BinaryField(null=True)
class Meta: class Meta:
verbose_name = "Page" verbose_name = "Page"

View File

@ -13,12 +13,9 @@ class InstanceSerializer(BaseSerializer):
model = Instance model = Instance
exclude = [ exclude = [
"license_key", "license_key",
"api_key",
"version",
] ]
read_only_fields = [ read_only_fields = [
"id", "id",
"instance_id",
"email", "email",
"last_checked_at", "last_checked_at",
"is_setup_done", "is_setup_done",

View File

@ -148,7 +148,7 @@ class InstanceEndpoint(BaseAPIView):
data["app_base_url"] = settings.APP_BASE_URL data["app_base_url"] = settings.APP_BASE_URL
instance_data = serializer.data instance_data = serializer.data
instance_data["workspaces_exist"] = Workspace.objects.count() > 1 instance_data["workspaces_exist"] = Workspace.objects.count() >= 1
response_data = {"config": data, "instance": instance_data} response_data = {"config": data, "instance": instance_data}
return Response(response_data, status=status.HTTP_200_OK) return Response(response_data, status=status.HTTP_200_OK)

View File

@ -49,8 +49,8 @@ class Command(BaseCommand):
instance_name="Plane Community Edition", instance_name="Plane Community Edition",
instance_id=secrets.token_hex(12), instance_id=secrets.token_hex(12),
license_key=None, license_key=None,
api_key=secrets.token_hex(8), current_version=payload.get("version"),
version=payload.get("version"), latest_version=payload.get("version"),
last_checked_at=timezone.now(), last_checked_at=timezone.now(),
user_count=payload.get("user_count", 0), user_count=payload.get("user_count", 0),
) )

View File

@ -0,0 +1,106 @@
# Generated by Django 4.2.11 on 2024-05-31 10:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("license", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="instance",
name="instance_id",
field=models.CharField(max_length=255, unique=True),
),
migrations.RenameField(
model_name="instance",
old_name="version",
new_name="current_version",
),
migrations.RemoveField(
model_name="instance",
name="api_key",
),
migrations.AddField(
model_name="instance",
name="domain",
field=models.TextField(blank=True),
),
migrations.AddField(
model_name="instance",
name="latest_version",
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AddField(
model_name="instance",
name="product",
field=models.CharField(default="plane-ce", max_length=50),
),
migrations.CreateModel(
name="ChangeLog",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("title", models.CharField(max_length=100)),
("description", models.TextField(blank=True)),
("version", models.CharField(max_length=100)),
("tags", models.JSONField(default=list)),
("release_date", models.DateTimeField(null=True)),
("is_release_candidate", models.BooleanField(default=False)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
],
options={
"verbose_name": "Change Log",
"verbose_name_plural": "Change Logs",
"db_table": "changelogs",
"ordering": ("-created_at",),
},
),
]

View File

@ -1,3 +1,6 @@
# Python imports
from enum import Enum
# Django imports # Django imports
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
@ -8,15 +11,23 @@ from plane.db.models import BaseModel
ROLE_CHOICES = ((20, "Admin"),) ROLE_CHOICES = ((20, "Admin"),)
class ProductTypes(Enum):
PLANE_CE = "plane-ce"
class Instance(BaseModel): class Instance(BaseModel):
# General informations # General information
instance_name = models.CharField(max_length=255) instance_name = models.CharField(max_length=255)
whitelist_emails = models.TextField(blank=True, null=True) whitelist_emails = models.TextField(blank=True, null=True)
instance_id = models.CharField(max_length=25, unique=True) instance_id = models.CharField(max_length=255, unique=True)
license_key = models.CharField(max_length=256, null=True, blank=True) license_key = models.CharField(max_length=256, null=True, blank=True)
api_key = models.CharField(max_length=16) current_version = models.CharField(max_length=10)
version = models.CharField(max_length=10) latest_version = models.CharField(max_length=10, null=True, blank=True)
# Instnace specifics product = models.CharField(
max_length=50, default=ProductTypes.PLANE_CE.value
)
domain = models.TextField(blank=True)
# Instance specifics
last_checked_at = models.DateTimeField() last_checked_at = models.DateTimeField()
namespace = models.CharField(max_length=50, blank=True, null=True) namespace = models.CharField(max_length=50, blank=True, null=True)
# telemetry and support # telemetry and support
@ -70,3 +81,20 @@ class InstanceConfiguration(BaseModel):
verbose_name_plural = "Instance Configurations" verbose_name_plural = "Instance Configurations"
db_table = "instance_configurations" db_table = "instance_configurations"
ordering = ("-created_at",) ordering = ("-created_at",)
class ChangeLog(BaseModel):
"""Change Log model to store the release changelogs made in the application."""
title = models.CharField(max_length=100)
description = models.TextField(blank=True)
version = models.CharField(max_length=100)
tags = models.JSONField(default=list)
release_date = models.DateTimeField(null=True)
is_release_candidate = models.BooleanField(default=False)
class Meta:
verbose_name = "Change Log"
verbose_name_plural = "Change Logs"
db_table = "changelogs"
ordering = ("-created_at",)

View File

@ -225,6 +225,9 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# Storage Settings # Storage Settings
# Use Minio settings
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
STORAGES = { STORAGES = {
"staticfiles": { "staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
@ -243,7 +246,7 @@ AWS_S3_FILE_OVERWRITE = False
AWS_S3_ENDPOINT_URL = os.environ.get( AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", None "AWS_S3_ENDPOINT_URL", None
) or os.environ.get("MINIO_ENDPOINT_URL", None) ) or os.environ.get("MINIO_ENDPOINT_URL", None)
if AWS_S3_ENDPOINT_URL: if AWS_S3_ENDPOINT_URL and USE_MINIO:
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
@ -307,8 +310,6 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
# Use Minio settings
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
# Posthog settings # Posthog settings
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False) POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
@ -350,4 +351,4 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
# Base URLs # Base URLs
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
APP_BASE_URL = os.environ.get("APP_BASE_URL") or os.environ.get("WEB_URL") APP_BASE_URL = os.environ.get("APP_BASE_URL")

View File

@ -128,7 +128,7 @@ services:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-} platform: ${DOCKER_PLATFORM:-}
pull_policy: ${PULL_POLICY:-always} pull_policy: ${PULL_POLICY:-always}
restart: no restart: "no"
command: ./bin/docker-entrypoint-migrator.sh command: ./bin/docker-entrypoint-migrator.sh
volumes: volumes:
- logs_migrator:/code/plane/logs - logs_migrator:/code/plane/logs

View File

@ -1,32 +0,0 @@
[supervisord] ## This is the main process for the Supervisor
nodaemon=true
[program:node]
command=sh /usr/local/bin/start.sh
autostart=true
autorestart=true
stderr_logfile=/var/log/node.err.log
stdout_logfile=/var/log/node.out.log
[program:python]
directory=/code
command=sh bin/docker-entrypoint-api.sh
autostart=true
autorestart=true
stderr_logfile=/var/log/python.err.log
stdout_logfile=/var/log/python.out.log
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stderr_logfile=/var/log/nginx.err.log
stdout_logfile=/var/log/nginx.out.log
[program:worker]
directory=/code
command=sh bin/worker
autostart=true
autorestart=true
stderr_logfile=/var/log/worker.err.log
stdout_logfile=/var/log/worker.out.log

View File

@ -1,6 +1,6 @@
{ {
"repository": "https://github.com/makeplane/plane.git", "repository": "https://github.com/makeplane/plane.git",
"version": "0.20.0", "version": "0.21.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"workspaces": [ "workspaces": [

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/constants", "name": "@plane/constants",
"version": "0.20.0", "version": "0.21.0",
"private": true, "private": true,
"main": "./src/index.ts", "main": "./src/index.ts",
"exports": { "exports": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/editor-core", "name": "@plane/editor-core",
"version": "0.20.0", "version": "0.21.0",
"description": "Core Editor that powers Plane", "description": "Core Editor that powers Plane",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -13,17 +13,21 @@ import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items
import { EditorRefApi } from "src/types/editor-ref-api"; import { EditorRefApi } from "src/types/editor-ref-api";
import { IMarking, scrollSummary } from "src/helpers/scroll-to-node"; import { IMarking, scrollSummary } from "src/helpers/scroll-to-node";
interface CustomEditorProps { export type TFileHandler = {
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
export interface CustomEditorProps {
id?: string; id?: string;
uploadFile: UploadImage; fileHandler: TFileHandler;
restoreFile: RestoreImage; initialValue?: string;
deleteFile: DeleteImage;
cancelUploadImage?: () => void;
initialValue: string;
editorClassName: string; editorClassName: string;
// undefined when prop is not passed, null if intentionally passed to stop // undefined when prop is not passed, null if intentionally passed to stop
// swr syncing // swr syncing
value: string | null | undefined; value?: string | null | undefined;
onChange?: (json: object, html: string) => void; onChange?: (json: object, html: string) => void;
extensions?: any; extensions?: any;
editorProps?: EditorProps; editorProps?: EditorProps;
@ -38,19 +42,16 @@ interface CustomEditorProps {
} }
export const useEditor = ({ export const useEditor = ({
uploadFile,
id = "", id = "",
deleteFile,
cancelUploadImage,
editorProps = {}, editorProps = {},
initialValue, initialValue,
editorClassName, editorClassName,
value, value,
extensions = [], extensions = [],
fileHandler,
onChange, onChange,
forwardedRef, forwardedRef,
tabIndex, tabIndex,
restoreFile,
handleEditorReady, handleEditorReady,
mentionHandler, mentionHandler,
placeholder, placeholder,
@ -67,10 +68,10 @@ export const useEditor = ({
mentionHighlights: mentionHandler.highlights ?? [], mentionHighlights: mentionHandler.highlights ?? [],
}, },
fileConfig: { fileConfig: {
deleteFile, uploadFile: fileHandler.upload,
restoreFile, deleteFile: fileHandler.delete,
cancelUploadImage, restoreFile: fileHandler.restore,
uploadFile, cancelUploadImage: fileHandler.cancel,
}, },
placeholder, placeholder,
tabIndex, tabIndex,
@ -139,14 +140,14 @@ export const useEditor = ({
} }
}, },
executeMenuItemCommand: (itemName: EditorMenuItemNames) => { executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
const editorItems = getEditorMenuItems(editorRef.current, uploadFile); const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName); const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName); const item = getEditorMenuItem(itemName);
if (item) { if (item) {
if (item.key === "image") { if (item.key === "image") {
item.command(savedSelection); item.command(savedSelectionRef.current);
} else { } else {
item.command(); item.command();
} }
@ -155,7 +156,7 @@ export const useEditor = ({
} }
}, },
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => { isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
const editorItems = getEditorMenuItems(editorRef.current, uploadFile); const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName); const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName); const item = getEditorMenuItem(itemName);
@ -177,10 +178,15 @@ export const useEditor = ({
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput; return markdownOutput;
}, },
getHTML: (): string => {
const htmlOutput = editorRef.current?.getHTML() ?? "<p></p>";
return htmlOutput;
},
scrollSummary: (marking: IMarking): void => { scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return; if (!editorRef.current) return;
scrollSummary(editorRef.current, marking); scrollSummary(editorRef.current, marking);
}, },
isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false,
setFocusAtPosition: (position: number) => { setFocusAtPosition: (position: number) => {
if (!editorRef.current || editorRef.current.isDestroyed) { if (!editorRef.current || editorRef.current.isDestroyed) {
console.error("Editor reference is not available or has been destroyed."); console.error("Editor reference is not available or has been destroyed.");
@ -199,7 +205,7 @@ export const useEditor = ({
} }
}, },
}), }),
[editorRef, savedSelection, uploadFile] [editorRef, savedSelection, fileHandler.upload]
); );
if (!editor) { if (!editor) {

View File

@ -68,6 +68,10 @@ export const useReadOnlyEditor = ({
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput; return markdownOutput;
}, },
getHTML: (): string => {
const htmlOutput = editorRef.current?.getHTML() ?? "<p></p>";
return htmlOutput;
},
scrollSummary: (marking: IMarking): void => { scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return; if (!editorRef.current) return;
scrollSummary(editorRef.current, marking); scrollSummary(editorRef.current, marking);

View File

@ -9,7 +9,7 @@ export { isCellSelection } from "src/ui/extensions/table/table/utilities/is-cell
// utils // utils
export * from "src/lib/utils"; export * from "src/lib/utils";
export * from "src/ui/extensions/table/table"; export * from "src/ui/extensions/table/table";
export { startImageUpload } from "src/ui/plugins/upload-image"; export { startImageUpload } from "src/ui/plugins/image/image-upload-handler";
// components // components
export { EditorContainer } from "src/ui/components/editor-container"; export { EditorContainer } from "src/ui/components/editor-container";
@ -24,6 +24,7 @@ export * from "src/ui/menus/menu-items";
export * from "src/lib/editor-commands"; export * from "src/lib/editor-commands";
// types // types
export type { CustomEditorProps, TFileHandler } from "src/hooks/use-editor";
export type { DeleteImage } from "src/types/delete-image"; export type { DeleteImage } from "src/types/delete-image";
export type { UploadImage } from "src/types/upload-image"; export type { UploadImage } from "src/types/upload-image";
export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api"; export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api";

View File

@ -1,5 +1,5 @@
import { Editor, Range } from "@tiptap/core"; import { Editor, Range } from "@tiptap/core";
import { startImageUpload } from "src/ui/plugins/upload-image"; import { startImageUpload } from "src/ui/plugins/image/image-upload-handler";
import { findTableAncestor } from "src/lib/utils"; import { findTableAncestor } from "src/lib/utils";
import { Selection } from "@tiptap/pm/state"; import { Selection } from "@tiptap/pm/state";
import { UploadImage } from "src/types/upload-image"; import { UploadImage } from "src/types/upload-image";
@ -194,7 +194,7 @@ export const insertImageCommand = (
if (range) editor.chain().focus().deleteRange(range).run(); if (range) editor.chain().focus().deleteRange(range).run();
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = "image/*"; input.accept = ".jpeg, .jpg, .png, .webp, .svg";
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; const file = input.files[0];

View File

@ -1,5 +1,7 @@
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state"; import { Selection } from "@tiptap/pm/state";
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { CoreEditorExtensionsWithoutProps } from "src/ui/extensions/core-without-props";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
interface EditorClassNames { interface EditorClassNames {
noBorder?: boolean; noBorder?: boolean;
@ -58,3 +60,20 @@ export const isValidHttpUrl = (string: string): boolean => {
return url.protocol === "http:" || url.protocol === "https:"; return url.protocol === "http:" || url.protocol === "https:";
}; };
/**
* @description return an object with contentJSON and editorSchema
* @description contentJSON- ProseMirror JSON from HTML content
* @description editorSchema- editor schema from extensions
* @param {string} html
* @returns {object} {contentJSON, editorSchema}
*/
export const generateJSONfromHTML = (html: string) => {
const extensions = CoreEditorExtensionsWithoutProps();
const contentJSON = generateJSON(html ?? "<p></p>", extensions as Extensions);
const editorSchema = getSchema(extensions as Extensions);
return {
contentJSON,
editorSchema,
};
};

View File

@ -3,6 +3,7 @@ import { EditorMenuItemNames } from "src/ui/menus/menu-items";
export type EditorReadOnlyRefApi = { export type EditorReadOnlyRefApi = {
getMarkDown: () => string; getMarkDown: () => string;
getHTML: () => string;
clearEditor: () => void; clearEditor: () => void;
setEditorValue: (content: string) => void; setEditorValue: (content: string) => void;
scrollSummary: (marking: IMarking) => void; scrollSummary: (marking: IMarking) => void;
@ -14,4 +15,5 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean; isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
onStateChange: (callback: () => void) => () => void; onStateChange: (callback: () => void) => () => void;
setFocusAtPosition: (position: number) => void; setFocusAtPosition: (position: number) => void;
isEditorReadyToDiscard: () => boolean;
} }

View File

@ -0,0 +1,121 @@
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
import TiptapUnderline from "@tiptap/extension-underline";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "tiptap-markdown";
import { Table } from "src/ui/extensions/table/table";
import { TableCell } from "src/ui/extensions/table/table-cell/table-cell";
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
import { TableRow } from "src/ui/extensions/table/table-row/table-row";
import { isValidHttpUrl } from "src/lib/utils";
import { CustomCodeBlockExtension } from "src/ui/extensions/code";
import { CustomKeymap } from "src/ui/extensions/keymap";
import { CustomQuoteExtension } from "src/ui/extensions/quote";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
import { CustomTypographyExtension } from "src/ui/extensions/typography";
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
import { CustomCodeMarkPlugin } from "src/ui/extensions/custom-code-inline/inline-code-plugin";
import { MentionsWithoutProps } from "src/ui/mentions/mention-without-props";
import { ImageExtensionWithoutProps } from "src/ui/extensions/image/image-extension-without-props";
import StarterKit from "@tiptap/starter-kit";
export const CoreEditorExtensionsWithoutProps = () => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
},
},
listItem: {
HTMLAttributes: {
class: "not-prose space-y-2",
},
},
code: false,
codeBlock: false,
horizontalRule: false,
blockquote: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 1,
},
}),
CustomQuoteExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {
class: "my-4 border-custom-border-400",
},
}),
CustomKeymap,
// ListKeymap,
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"],
validate: (url: string) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
CustomTypographyExtension,
ImageExtensionWithoutProps().configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex",
},
nested: true,
}),
CustomCodeBlockExtension.configure({
HTMLAttributes: {
class: "",
},
}),
CustomCodeMarkPlugin,
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformPastedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
MentionsWithoutProps(),
Placeholder.configure({
placeholder: ({ editor, node }) => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
const shouldHidePlaceholder =
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
if (shouldHidePlaceholder) return "";
return "Press '/' for commands...";
},
includeChildren: true,
}),
];

View File

@ -1,7 +1,7 @@
import { Extension } from "@tiptap/core"; import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "prosemirror-state"; import { Plugin, PluginKey } from "prosemirror-state";
import { UploadImage } from "src/types/upload-image"; import { UploadImage } from "src/types/upload-image";
import { startImageUpload } from "../plugins/upload-image"; import { startImageUpload } from "src/ui/plugins/image/image-upload-handler";
export const DropHandlerExtension = (uploadFile: UploadImage) => export const DropHandlerExtension = (uploadFile: UploadImage) =>
Extension.create({ Extension.create({

View File

@ -0,0 +1,33 @@
import ImageExt from "@tiptap/extension-image";
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
export const ImageExtensionWithoutProps = () =>
ImageExt.extend({
addKeyboardShortcuts() {
return {
ArrowDown: insertLineBelowImageAction,
ArrowUp: insertLineAboveImageAction,
};
},
// storage to keep track of image states Map<src, isDeleted>
addStorage() {
return {
images: new Map<string, boolean>(),
uploadInProgress: false,
};
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
};
},
});

View File

@ -1,25 +1,16 @@
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; import { UploadImagesPlugin } from "src/ui/plugins/image/upload-image";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { UploadImagesPlugin } from "src/ui/plugins/upload-image";
import ImageExt from "@tiptap/extension-image"; import ImageExt from "@tiptap/extension-image";
import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image"; import { TrackImageDeletionPlugin } from "src/ui/plugins/image/delete-image";
import { DeleteImage } from "src/types/delete-image"; import { DeleteImage } from "src/types/delete-image";
import { RestoreImage } from "src/types/restore-image"; import { RestoreImage } from "src/types/restore-image";
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image"; import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image"; import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
import { TrackImageRestorationPlugin } from "src/ui/plugins/image/restore-image";
import { IMAGE_NODE_TYPE } from "src/ui/plugins/image/constants";
import { ImageExtensionStorage } from "src/ui/plugins/image/types/image-node";
interface ImageNode extends ProseMirrorNode { export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
attrs: { ImageExt.extend<any, ImageExtensionStorage>({
src: string;
id: string;
};
}
const deleteKey = new PluginKey("delete-image");
const IMAGE_NODE_TYPE = "image";
export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => void) =>
ImageExt.extend({
addKeyboardShortcuts() { addKeyboardShortcuts() {
return { return {
ArrowDown: insertLineBelowImageAction, ArrowDown: insertLineBelowImageAction,
@ -29,77 +20,8 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
UploadImagesPlugin(this.editor, cancelUploadImage), UploadImagesPlugin(this.editor, cancelUploadImage),
new Plugin({ TrackImageDeletionPlugin(this.editor, deleteImage),
key: deleteKey, TrackImageRestorationPlugin(this.editor, restoreImage),
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
// transaction could be a selection
if (!transaction.docChanged) return;
const removedImages: ImageNode[] = [];
// iterate through all the nodes in the old state
oldState.doc.descendants((oldNode, oldPos) => {
// if the node is not an image, then return as no point in checking
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
// Check if the node has been deleted or replaced
if (!newImageSources.has(oldNode.attrs.src)) {
removedImages.push(oldNode as ImageNode);
}
});
removedImages.forEach(async (node) => {
const src = node.attrs.src;
this.storage.images.set(src, true);
await onNodeDeleted(src, deleteImage);
});
});
return null;
},
}),
new Plugin({
key: new PluginKey("imageRestoration"),
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const oldImageSources = new Set<string>();
oldState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
oldImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const addedImages: ImageNode[] = [];
newState.doc.descendants((node, pos) => {
if (node.type.name !== IMAGE_NODE_TYPE) return;
if (pos < 0 || pos > newState.doc.content.size) return;
if (oldImageSources.has(node.attrs.src)) return;
addedImages.push(node as ImageNode);
});
addedImages.forEach(async (image) => {
const wasDeleted = this.storage.images.get(image.attrs.src);
if (wasDeleted === undefined) {
this.storage.images.set(image.attrs.src, false);
} else if (wasDeleted === true) {
await onNodeRestored(image.attrs.src, restoreFile);
}
});
});
return null;
},
}),
]; ];
}, },
@ -113,7 +35,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma
imageSources.forEach(async (src) => { imageSources.forEach(async (src) => {
try { try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreFile(assetUrlWithWorkspaceId); await restoreImage(assetUrlWithWorkspaceId);
} catch (error) { } catch (error) {
console.error("Error restoring image: ", error); console.error("Error restoring image: ", error);
} }
@ -123,7 +45,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma
// storage to keep track of image states Map<src, isDeleted> // storage to keep track of image states Map<src, isDeleted>
addStorage() { addStorage() {
return { return {
images: new Map<string, boolean>(), deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false, uploadInProgress: false,
}; };
}, },

View File

@ -141,8 +141,11 @@ export const CoreEditorExtensions = ({
placeholder: ({ editor, node }) => { placeholder: ({ editor, node }) => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`; if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
if (editor.storage.image.uploadInProgress) return "";
const shouldHidePlaceholder = const shouldHidePlaceholder =
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
if (shouldHidePlaceholder) return ""; if (shouldHidePlaceholder) return "";
if (placeholder) { if (placeholder) {

View File

@ -0,0 +1,79 @@
import { CustomMention } from "./custom";
import { ReactRenderer } from "@tiptap/react";
import { Editor } from "@tiptap/core";
import tippy from "tippy.js";
import { MentionList } from "./mention-list";
export const MentionsWithoutProps = () =>
CustomMention.configure({
HTMLAttributes: {
class: "mention",
},
// mentionHighlights: mentionHighlights,
suggestion: {
// @ts-expect-error - Tiptap types are incorrect
render: () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
if (!props.clientRect) {
return;
}
component = new ReactRenderer(MentionList, {
props: { ...props },
editor: props.editor,
});
props.editor.storage.mentionsOpen = true;
// @ts-expect-error - Tippy types are incorrect
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
if (!props.clientRect) {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
if (navigationKeys.includes(props.event.key)) {
// @ts-expect-error - Tippy types are incorrect
component?.ref?.onKeyDown(props);
event?.stopPropagation();
return true;
}
return false;
},
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
props.editor.storage.mentionsOpen = false;
popup?.[0].destroy();
component?.destroy();
},
};
},
},
});

View File

@ -239,8 +239,5 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
]; ];
} }
export type EditorMenuItemNames = ReturnType<typeof getEditorMenuItems> extends (infer U)[] export type EditorMenuItemNames =
? U extends { key: infer N } ReturnType<typeof getEditorMenuItems> extends (infer U)[] ? (U extends { key: infer N } ? N : never) : never;
? N
: never
: never;

View File

@ -1,73 +0,0 @@
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { DeleteImage } from "src/types/delete-image";
import { RestoreImage } from "src/types/restore-image";
const deleteKey = new PluginKey("delete-image");
const IMAGE_NODE_TYPE = "image";
interface ImageNode extends ProseMirrorNode {
attrs: {
src: string;
id: string;
};
}
export const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const removedImages: ImageNode[] = [];
oldState.doc.descendants((oldNode, oldPos) => {
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
if (oldPos < 0 || oldPos > newState.doc.content.size) return;
if (!newState.doc.resolve(oldPos).parent) return;
const newNode = newState.doc.nodeAt(oldPos);
// Check if the node has been deleted or replaced
if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) {
if (!newImageSources.has(oldNode.attrs.src)) {
removedImages.push(oldNode as ImageNode);
}
}
});
removedImages.forEach(async (node) => {
const src = node.attrs.src;
await onNodeDeleted(src, deleteImage);
});
});
return null;
},
});
export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await deleteImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error deleting image: ", error);
}
}
export async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error restoring image: ", error);
}
}

View File

@ -0,0 +1,7 @@
import { PluginKey } from "@tiptap/pm/state";
export const uploadKey = new PluginKey("upload-image");
export const deleteKey = new PluginKey("delete-image");
export const restoreKey = new PluginKey("restore-image");
export const IMAGE_NODE_TYPE = "image";

View File

@ -0,0 +1,54 @@
import { EditorState, Plugin, Transaction } from "@tiptap/pm/state";
import { DeleteImage } from "src/types/delete-image";
import { Editor } from "@tiptap/core";
import { type ImageNode } from "src/ui/plugins/image/types/image-node";
import { deleteKey, IMAGE_NODE_TYPE } from "src/ui/plugins/image/constants";
export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
// transaction could be a selection
if (!transaction.docChanged) return;
const removedImages: ImageNode[] = [];
// iterate through all the nodes in the old state
oldState.doc.descendants((oldNode) => {
// if the node is not an image, then return as no point in checking
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
// Check if the node has been deleted or replaced
if (!newImageSources.has(oldNode.attrs.src)) {
removedImages.push(oldNode as ImageNode);
}
});
removedImages.forEach(async (node) => {
const src = node.attrs.src;
editor.storage.image.deletedImageSet.set(src, true);
await onNodeDeleted(src, deleteImage);
});
});
return null;
},
});
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await deleteImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error deleting image: ", error);
}
}

View File

@ -0,0 +1,114 @@
import { type UploadImage } from "src/types/upload-image";
// utilities
import { v4 as uuidv4 } from "uuid";
// types
import { isFileValid } from "src/ui/plugins/image/utils/validate-file";
import { Editor } from "@tiptap/core";
import { EditorView } from "@tiptap/pm/view";
import { uploadKey } from "./constants";
import { removePlaceholder, findPlaceholder } from "./utils/placeholder";
export async function startImageUpload(
editor: Editor,
file: File,
view: EditorView,
pos: number | null,
uploadFile: UploadImage
) {
editor.storage.image.uploadInProgress = true;
if (!isFileValid(file)) {
editor.storage.image.uploadInProgress = false;
return;
}
const id = uuidv4();
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
tr.setMeta(uploadKey, {
add: {
id,
pos,
src: reader.result,
},
});
view.dispatch(tr);
};
// Handle FileReader errors
reader.onerror = (error) => {
console.error("FileReader error: ", error);
removePlaceholder(editor, view, id);
return;
};
try {
view.focus();
const src = await uploadAndValidateImage(file, uploadFile);
if (src == null) {
throw new Error("Resolved image URL is undefined.");
}
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) {
editor.storage.image.uploadInProgress = false;
return;
}
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
if (pos < 0 || pos > view.state.doc.content.size) {
throw new Error("Invalid position to insert the image node.");
}
// insert the image node at the position of the placeholder and remove the placeholder
const transaction = view.state.tr.insert(pos, node).setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
editor.storage.image.uploadInProgress = false;
} catch (error) {
console.error("Error in uploading and inserting image: ", error);
removePlaceholder(editor, view, id);
}
}
async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Promise<string | undefined> {
try {
const imageUrl = await uploadFile(file);
if (imageUrl == null) {
throw new Error("Image URL is undefined.");
}
await new Promise<void>((resolve, reject) => {
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve();
};
image.onerror = (error) => {
console.error("Error in loading image: ", error);
reject(error);
};
});
return imageUrl;
} catch (error) {
console.error("Error in uploading image: ", error);
// throw error to remove the placeholder
throw error;
}
}

View File

@ -0,0 +1,57 @@
import { Editor } from "@tiptap/core";
import { EditorState, Plugin, Transaction } from "@tiptap/pm/state";
import { RestoreImage } from "src/types/restore-image";
import { restoreKey, IMAGE_NODE_TYPE } from "./constants";
import { type ImageNode } from "./types/image-node";
export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage): Plugin =>
new Plugin({
key: restoreKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const oldImageSources = new Set<string>();
oldState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
oldImageSources.add(node.attrs.src);
}
});
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const addedImages: ImageNode[] = [];
newState.doc.descendants((node, pos) => {
if (node.type.name !== IMAGE_NODE_TYPE) return;
if (pos < 0 || pos > newState.doc.content.size) return;
if (oldImageSources.has(node.attrs.src)) return;
addedImages.push(node as ImageNode);
});
addedImages.forEach(async (image) => {
const wasDeleted = editor.storage.image.deletedImageSet.get(image.attrs.src);
if (wasDeleted === undefined) {
editor.storage.image.deletedImageSet.set(image.attrs.src, false);
} else if (wasDeleted === true) {
try {
await onNodeRestored(image.attrs.src, restoreImage);
editor.storage.image.deletedImageSet.set(image.attrs.src, false);
} catch (error) {
console.error("Error restoring image: ", error);
}
}
});
});
return null;
},
});
async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error restoring image: ", error);
throw error;
}
}

View File

@ -0,0 +1,13 @@
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
export interface ImageNode extends ProseMirrorNode {
attrs: {
src: string;
id: string;
};
}
export type ImageExtensionStorage = {
deletedImageSet: Map<string, boolean>;
uploadInProgress: boolean;
};

View File

@ -0,0 +1,91 @@
import { Editor } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
// utils
import { removePlaceholder } from "src/ui/plugins/image/utils/placeholder";
// constants
import { uploadKey } from "src/ui/plugins/image/constants";
export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => {
let currentView: EditorView | null = null;
const createPlaceholder = (src: string): HTMLElement => {
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300");
image.src = src;
placeholder.appendChild(image);
return placeholder;
};
const createCancelButton = (id: string): HTMLButtonElement => {
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.style.position = "absolute";
cancelButton.style.right = "3px";
cancelButton.style.top = "3px";
cancelButton.setAttribute("class", "opacity-90 rounded-lg");
cancelButton.onclick = () => {
if (currentView) {
cancelUploadImage?.();
removePlaceholder(editor, currentView, id);
}
};
// Create an SVG element from the SVG string
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-circle"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`;
const parser = new DOMParser();
const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement;
cancelButton.appendChild(svgElement);
return cancelButton;
};
return new Plugin({
key: uploadKey,
view(editorView) {
currentView = editorView;
return {
destroy() {
currentView = null;
},
};
},
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
const action = tr.getMeta(uploadKey);
if (action && action.add) {
const { id, pos, src } = action.add;
const placeholder = createPlaceholder(src);
const cancelButton = createCancelButton(id);
placeholder.appendChild(cancelButton);
const deco = Decoration.widget(pos, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
};

View File

@ -0,0 +1,16 @@
import { Editor } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";
import { DecorationSet, EditorView } from "@tiptap/pm/view";
import { uploadKey } from "src/ui/plugins/image/constants";
export function findPlaceholder(state: EditorState, id: string): number | null {
const decos = uploadKey.getState(state) as DecorationSet;
const found = decos.find(undefined, undefined, (spec: { id: string }) => spec.id === id);
return found.length ? found[0].from : null;
}
export function removePlaceholder(editor: Editor, view: EditorView, id: string) {
const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { remove: { id } });
view.dispatch(removePlaceholderTr);
editor.storage.image.uploadInProgress = false;
}

View File

@ -0,0 +1,19 @@
export function isFileValid(file: File): boolean {
if (!file) {
alert("No file selected. Please select a file to upload.");
return false;
}
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/svg+xml"];
if (!allowedTypes.includes(file.type)) {
alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP, or SVG image file.");
return false;
}
if (file.size > 5 * 1024 * 1024) {
alert("File size too large. Please select a file smaller than 5MB.");
return false;
}
return true;
}

View File

@ -1,189 +0,0 @@
import { Editor } from "@tiptap/core";
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import { UploadImage } from "src/types/upload-image";
const uploadKey = new PluginKey("upload-image");
export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => {
let currentView: EditorView | null = null;
return new Plugin({
key: uploadKey,
view(editorView) {
currentView = editorView;
return {
destroy() {
currentView = null;
},
};
},
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
const action = tr.getMeta(uploadKey);
if (action && action.add) {
const { id, pos, src } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300");
image.src = src;
placeholder.appendChild(image);
// Create cancel button
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.style.position = "absolute";
cancelButton.style.right = "3px";
cancelButton.style.top = "3px";
cancelButton.setAttribute("class", "opacity-90 rounded-lg");
cancelButton.onclick = () => {
if (currentView) {
cancelUploadImage?.();
removePlaceholder(editor, currentView, id);
}
};
// Create an SVG element from the SVG string
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-circle"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`;
const parser = new DOMParser();
const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement;
cancelButton.appendChild(svgElement);
placeholder.appendChild(cancelButton);
const deco = Decoration.widget(pos, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
};
function findPlaceholder(state: EditorState, id: {}) {
const decos = uploadKey.getState(state);
const found = decos.find(undefined, undefined, (spec: { id: number | undefined }) => spec.id == id);
return found.length ? found[0].from : null;
}
const removePlaceholder = (editor: Editor, view: EditorView, id: {}) => {
const removePlaceholderTr = view.state.tr.setMeta(uploadKey, {
remove: { id },
});
view.dispatch(removePlaceholderTr);
editor.storage.image.uploadInProgress = false;
};
export async function startImageUpload(
editor: Editor,
file: File,
view: EditorView,
pos: number,
uploadFile: UploadImage
) {
editor.storage.image.uploadInProgress = true;
if (!file) {
alert("No file selected. Please select a file to upload.");
editor.storage.image.uploadInProgress = false;
return;
}
if (!file.type.includes("image/")) {
alert("Invalid file type. Please select an image file.");
editor.storage.image.uploadInProgress = false;
return;
}
if (file.size > 5 * 1024 * 1024) {
alert("File size too large. Please select a file smaller than 5MB.");
editor.storage.image.uploadInProgress = false;
return;
}
const id = {};
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
tr.setMeta(uploadKey, {
add: {
id,
pos,
src: reader.result,
},
});
view.dispatch(tr);
};
// Handle FileReader errors
reader.onerror = (error) => {
console.error("FileReader error: ", error);
removePlaceholder(editor, view, id);
return;
};
// setIsSubmitting?.("submitting");
try {
const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) {
editor.storage.image.uploadInProgress = false;
return;
}
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
if (view.hasFocus()) view.focus();
editor.storage.image.uploadInProgress = false;
} catch (error) {
removePlaceholder(editor, view, id);
}
}
const UploadImageHandler = (file: File, uploadFile: UploadImage): Promise<string> => {
try {
return new Promise(async (resolve, reject) => {
try {
const imageUrl = await uploadFile(file);
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve(imageUrl);
};
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
reject(error);
}
});
} catch (error) {
return Promise.reject(error);
}
};

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/document-editor", "name": "@plane/document-editor",
"version": "0.20.0", "version": "0.21.0",
"description": "Package that powers Plane's Pages Editor", "description": "Package that powers Plane's Pages Editor",
"main": "./dist/index.mjs", "main": "./dist/index.mjs",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
@ -34,12 +34,17 @@
"@plane/ui": "*", "@plane/ui": "*",
"@tippyjs/react": "^4.2.6", "@tippyjs/react": "^4.2.6",
"@tiptap/core": "^2.1.13", "@tiptap/core": "^2.1.13",
"@tiptap/extension-collaboration": "^2.3.2",
"@tiptap/pm": "^2.1.13", "@tiptap/pm": "^2.1.13",
"@tiptap/suggestion": "^2.1.13", "@tiptap/suggestion": "^2.1.13",
"lucide-react": "^0.378.0", "lucide-react": "^0.378.0",
"react-popper": "^2.3.0", "react-popper": "^2.3.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"uuid": "^9.0.1" "uuid": "^9.0.1",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.5",
"y-protocols": "^1.0.6",
"yjs": "^13.6.15"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "18.15.3", "@types/node": "18.15.3",

View File

@ -0,0 +1,85 @@
import { useEffect, useLayoutEffect, useMemo } from "react";
import { EditorProps } from "@tiptap/pm/view";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
// editor-core
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TFileHandler, useEditor } from "@plane/editor-core";
// custom provider
import { CollaborationProvider } from "src/providers/collaboration-provider";
// extensions
import { DocumentEditorExtensions } from "src/ui/extensions";
type DocumentEditorProps = {
id: string;
fileHandler: TFileHandler;
value: Uint8Array;
editorClassName: string;
onChange: (updates: Uint8Array) => void;
editorProps?: EditorProps;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>;
};
handleEditorReady?: (value: boolean) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void;
tabIndex?: number;
};
export const useDocumentEditor = ({
id,
editorProps = {},
value,
editorClassName,
fileHandler,
onChange,
forwardedRef,
tabIndex,
handleEditorReady,
mentionHandler,
placeholder,
setHideDragHandleFunction,
}: DocumentEditorProps) => {
const provider = useMemo(
() =>
new CollaborationProvider({
name: id,
onChange,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[id]
);
// update document on value change
useEffect(() => {
if (value.byteLength > 0) Y.applyUpdate(provider.document, value);
}, [value, provider.document]);
// indexedDB provider
useLayoutEffect(() => {
const localProvider = new IndexeddbPersistence(id, provider.document);
return () => {
localProvider?.destroy();
};
}, [provider, id]);
const editor = useEditor({
id,
editorProps,
editorClassName,
fileHandler,
handleEditorReady,
forwardedRef,
mentionHandler,
extensions: DocumentEditorExtensions({
uploadFile: fileHandler.upload,
setHideDragHandle: setHideDragHandleFunction,
provider,
}),
placeholder,
tabIndex,
});
return editor;
};

View File

@ -3,6 +3,8 @@ export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/re
// hooks // hooks
export { useEditorMarkings } from "src/hooks/use-editor-markings"; export { useEditorMarkings } from "src/hooks/use-editor-markings";
// utils
export { proseMirrorJSONToBinaryString, applyUpdates, mergeUpdates } from "src/utils/yjs";
export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core"; export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core";

View File

@ -0,0 +1,60 @@
import * as Y from "yjs";
export interface CompleteCollaboratorProviderConfiguration {
/**
* The identifier/name of your document
*/
name: string;
/**
* The actual Y.js document
*/
document: Y.Doc;
/**
* onChange callback
*/
onChange: (updates: Uint8Array) => void;
}
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
Partial<CompleteCollaboratorProviderConfiguration>;
export class CollaborationProvider {
public configuration: CompleteCollaboratorProviderConfiguration = {
name: "",
// @ts-expect-error cannot be undefined
document: undefined,
onChange: () => {},
};
constructor(configuration: CollaborationProviderConfiguration) {
this.setConfiguration(configuration);
this.configuration.document = configuration.document ?? new Y.Doc();
this.document.on("update", this.documentUpdateHandler.bind(this));
this.document.on("destroy", this.documentDestroyHandler.bind(this));
}
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
this.configuration = {
...this.configuration,
...configuration,
};
}
get document() {
return this.configuration.document;
}
documentUpdateHandler(update: Uint8Array, origin: any) {
// return if the update is from the provider itself
if (origin === this) return;
// call onChange with the update
this.configuration.onChange?.(update);
}
documentDestroyHandler() {
this.document.off("update", this.documentUpdateHandler);
this.document.off("destroy", this.documentDestroyHandler);
}
}

View File

@ -2,14 +2,20 @@ import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-wi
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
import { UploadImage } from "@plane/editor-core"; import { UploadImage } from "@plane/editor-core";
import { CollaborationProvider } from "src/providers/collaboration-provider";
import Collaboration from "@tiptap/extension-collaboration";
type TArguments = { type TArguments = {
uploadFile: UploadImage; uploadFile: UploadImage;
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
provider: CollaborationProvider;
}; };
export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle }: TArguments) => [ export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle, provider }: TArguments) => [
SlashCommand(uploadFile), SlashCommand(uploadFile),
DragAndDrop(setHideDragHandle), DragAndDrop(setHideDragHandle),
IssueWidgetPlaceholder(), IssueWidgetPlaceholder(),
Collaboration.configure({
document: provider.document,
}),
]; ];

View File

@ -1,30 +1,25 @@
import React, { useState } from "react"; import React, { useState } from "react";
// editor-core
import { import {
UploadImage,
DeleteImage,
RestoreImage,
getEditorClassNames, getEditorClassNames,
useEditor,
EditorRefApi, EditorRefApi,
IMentionHighlight, IMentionHighlight,
IMentionSuggestion, IMentionSuggestion,
TFileHandler,
} from "@plane/editor-core"; } from "@plane/editor-core";
import { DocumentEditorExtensions } from "src/ui/extensions"; // components
import { PageRenderer } from "src/ui/components/page-renderer"; import { PageRenderer } from "src/ui/components/page-renderer";
// hooks
import { useDocumentEditor } from "src/hooks/use-document-editor";
interface IDocumentEditor { interface IDocumentEditor {
initialValue: string; id: string;
value?: string; value: Uint8Array;
fileHandler: { fileHandler: TFileHandler;
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
handleEditorReady?: (value: boolean) => void; handleEditorReady?: (value: boolean) => void;
containerClassName?: string; containerClassName?: string;
editorClassName?: string; editorClassName?: string;
onChange: (json: object, html: string) => void; onChange: (updates: Uint8Array) => void;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>; forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
mentionHandler: { mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>; highlights: () => Promise<IMentionHighlight[]>;
@ -37,7 +32,7 @@ interface IDocumentEditor {
const DocumentEditor = (props: IDocumentEditor) => { const DocumentEditor = (props: IDocumentEditor) => {
const { const {
onChange, onChange,
initialValue, id,
value, value,
fileHandler, fileHandler,
containerClassName, containerClassName,
@ -50,32 +45,24 @@ const DocumentEditor = (props: IDocumentEditor) => {
} = props; } = props;
// states // states
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {}); const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin // this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
// loads such that we can invoke it from react when the cursor leaves the container // loads such that we can invoke it from react when the cursor leaves the container
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => { const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop); setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
}; };
// use editor
const editor = useEditor({ // use document editor
onChange(json, html) { const editor = useDocumentEditor({
onChange(json, html); id,
},
editorClassName, editorClassName,
restoreFile: fileHandler.restore, fileHandler,
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
cancelUploadImage: fileHandler.cancel,
initialValue,
value, value,
onChange,
handleEditorReady, handleEditorReady,
forwardedRef, forwardedRef,
mentionHandler, mentionHandler,
extensions: DocumentEditorExtensions({
uploadFile: fileHandler.upload,
setHideDragHandle: setHideDragHandleFunction,
}),
placeholder, placeholder,
setHideDragHandleFunction,
tabIndex, tabIndex,
}); });

View File

@ -0,0 +1,76 @@
import { Schema } from "@tiptap/pm/model";
import { prosemirrorJSONToYDoc } from "y-prosemirror";
import * as Y from "yjs";
const defaultSchema: Schema = new Schema({
nodes: {
text: {},
doc: { content: "text*" },
},
});
/**
* @description converts ProseMirror JSON to Yjs document
* @param document prosemirror JSON
* @param fieldName
* @param schema
* @returns {Y.Doc} Yjs document
*/
export const proseMirrorJSONToBinaryString = (
document: any,
fieldName: string | Array<string> = "default",
schema?: Schema
): string => {
if (!document) {
throw new Error(
`You've passed an empty or invalid document to the Transformer. Make sure to pass ProseMirror-compatible JSON. Actually passed JSON: ${document}`
);
}
// allow a single field name
if (typeof fieldName === "string") {
const yDoc = prosemirrorJSONToYDoc(schema ?? defaultSchema, document, fieldName);
const docAsUint8Array = Y.encodeStateAsUpdate(yDoc);
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
return base64Doc;
}
const yDoc = new Y.Doc();
fieldName.forEach((field) => {
const update = Y.encodeStateAsUpdate(prosemirrorJSONToYDoc(schema ?? defaultSchema, document, field));
Y.applyUpdate(yDoc, update);
});
const docAsUint8Array = Y.encodeStateAsUpdate(yDoc);
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
return base64Doc;
};
/**
* @description apply updates to a doc and return the updated doc in base64(binary) format
* @param {Uint8Array} document
* @param {Uint8Array} updates
* @returns {string} base64(binary) form of the updated doc
*/
export const applyUpdates = (document: Uint8Array, updates: Uint8Array): string => {
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, document);
Y.applyUpdate(yDoc, updates);
const encodedDoc = Y.encodeStateAsUpdate(yDoc);
const base64Updates = Buffer.from(encodedDoc).toString("base64");
return base64Updates;
};
/**
* @description merge multiple updates into one single update
* @param {Uint8Array[]} updates
* @returns {Uint8Array} merged updates
*/
export const mergeUpdates = (updates: Uint8Array[]): Uint8Array => {
const mergedUpdates = Y.mergeUpdates(updates);
return mergedUpdates;
};

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/editor-extensions", "name": "@plane/editor-extensions",
"version": "0.20.0", "version": "0.21.0",
"description": "Package that powers Plane's Editor with extensions", "description": "Package that powers Plane's Editor with extensions",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/lite-text-editor", "name": "@plane/lite-text-editor",
"version": "0.20.0", "version": "0.21.0",
"description": "Package that powers Plane's Comment Editor", "description": "Package that powers Plane's Comment Editor",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -1,27 +1,22 @@
import * as React from "react"; import * as React from "react";
// editor-core
import { import {
UploadImage,
DeleteImage,
IMentionSuggestion, IMentionSuggestion,
RestoreImage,
EditorContainer, EditorContainer,
EditorContentWrapper, EditorContentWrapper,
getEditorClassNames, getEditorClassNames,
useEditor, useEditor,
IMentionHighlight, IMentionHighlight,
EditorRefApi, EditorRefApi,
TFileHandler,
} from "@plane/editor-core"; } from "@plane/editor-core";
// extensions
import { LiteTextEditorExtensions } from "src/ui/extensions"; import { LiteTextEditorExtensions } from "src/ui/extensions";
export interface ILiteTextEditor { export interface ILiteTextEditor {
initialValue: string; initialValue: string;
value?: string | null; value?: string | null;
fileHandler: { fileHandler: TFileHandler;
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
containerClassName?: string; containerClassName?: string;
editorClassName?: string; editorClassName?: string;
onChange?: (json: object, html: string) => void; onChange?: (json: object, html: string) => void;
@ -58,10 +53,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
value, value,
id, id,
editorClassName, editorClassName,
restoreFile: fileHandler.restore, fileHandler,
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
cancelUploadImage: fileHandler.cancel,
forwardedRef, forwardedRef,
extensions: LiteTextEditorExtensions(onEnterKeyPress), extensions: LiteTextEditorExtensions(onEnterKeyPress),
mentionHandler, mentionHandler,

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/rich-text-editor", "name": "@plane/rich-text-editor",
"version": "0.20.0", "version": "0.21.0",
"description": "Rich Text Editor that powers Plane", "description": "Rich Text Editor that powers Plane",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -0,0 +1,25 @@
import { Extension } from "@tiptap/core";
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
Extension.create({
name: "enterKey",
addKeyboardShortcuts(this) {
return {
Enter: () => {
if (onEnterKeyPress) {
onEnterKeyPress();
}
return true;
},
"Shift-Enter": ({ editor }) =>
editor.commands.first(({ commands }) => [
() => commands.newlineInCode(),
() => commands.splitListItem("listItem"),
() => commands.createParagraphNear(),
() => commands.liftEmptyBlock(),
() => commands.splitBlock(),
]),
};
},
});

View File

@ -1,13 +1,22 @@
import { UploadImage } from "@plane/editor-core"; import { UploadImage } from "@plane/editor-core";
import { DragAndDrop, SlashCommand } from "@plane/editor-extensions"; import { DragAndDrop, SlashCommand } from "@plane/editor-extensions";
import { EnterKeyExtension } from "./enter-key-extension";
type TArguments = { type TArguments = {
uploadFile: UploadImage; uploadFile: UploadImage;
dragDropEnabled?: boolean; dragDropEnabled?: boolean;
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
onEnterKeyPress?: () => void;
}; };
export const RichTextEditorExtensions = ({ uploadFile, dragDropEnabled, setHideDragHandle }: TArguments) => [ export const RichTextEditorExtensions = ({
uploadFile,
dragDropEnabled,
setHideDragHandle,
onEnterKeyPress,
}: TArguments) => [
SlashCommand(uploadFile), SlashCommand(uploadFile),
dragDropEnabled === true && DragAndDrop(setHideDragHandle), dragDropEnabled === true && DragAndDrop(setHideDragHandle),
// TODO; add the extension conditionally for forms that don't require it
// EnterKeyExtension(onEnterKeyPress),
]; ];

View File

@ -1,30 +1,26 @@
"use client"; "use client";
import * as React from "react";
// editor-core
import { import {
DeleteImage,
EditorContainer, EditorContainer,
EditorContentWrapper, EditorContentWrapper,
getEditorClassNames, getEditorClassNames,
IMentionHighlight, IMentionHighlight,
IMentionSuggestion, IMentionSuggestion,
RestoreImage,
UploadImage,
useEditor, useEditor,
EditorRefApi, EditorRefApi,
TFileHandler,
} from "@plane/editor-core"; } from "@plane/editor-core";
import * as React from "react"; // extensions
import { RichTextEditorExtensions } from "src/ui/extensions"; import { RichTextEditorExtensions } from "src/ui/extensions";
// components
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu"; import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
export type IRichTextEditor = { export type IRichTextEditor = {
initialValue: string; initialValue: string;
value?: string | null; value?: string | null;
dragDropEnabled?: boolean; dragDropEnabled?: boolean;
fileHandler: { fileHandler: TFileHandler;
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
id?: string; id?: string;
containerClassName?: string; containerClassName?: string;
editorClassName?: string; editorClassName?: string;
@ -37,6 +33,7 @@ export type IRichTextEditor = {
}; };
placeholder?: string | ((isFocused: boolean, value: string) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number; tabIndex?: number;
onEnterKeyPress?: (e?: any) => void;
}; };
const RichTextEditor = (props: IRichTextEditor) => { const RichTextEditor = (props: IRichTextEditor) => {
@ -54,6 +51,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
placeholder, placeholder,
tabIndex, tabIndex,
mentionHandler, mentionHandler,
onEnterKeyPress,
} = props; } = props;
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
@ -67,10 +65,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
const editor = useEditor({ const editor = useEditor({
id, id,
editorClassName, editorClassName,
restoreFile: fileHandler.restore, fileHandler,
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
cancelUploadImage: fileHandler.cancel,
onChange, onChange,
initialValue, initialValue,
value, value,
@ -80,6 +75,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
uploadFile: fileHandler.upload, uploadFile: fileHandler.upload,
dragDropEnabled, dragDropEnabled,
setHideDragHandle: setHideDragHandleFunction, setHideDragHandle: setHideDragHandleFunction,
onEnterKeyPress,
}), }),
tabIndex, tabIndex,
mentionHandler, mentionHandler,

View File

@ -1,7 +1,7 @@
{ {
"name": "eslint-config-custom", "name": "eslint-config-custom",
"private": true, "private": true,
"version": "0.20.0", "version": "0.21.0",
"main": "index.js", "main": "index.js",
"license": "MIT", "license": "MIT",
"devDependencies": {}, "devDependencies": {},

View File

@ -1,6 +1,6 @@
{ {
"name": "tailwind-config-custom", "name": "tailwind-config-custom",
"version": "0.20.0", "version": "0.21.0",
"description": "common tailwind configuration across monorepo", "description": "common tailwind configuration across monorepo",
"main": "index.js", "main": "index.js",
"private": true, "private": true,

View File

@ -1,6 +1,6 @@
{ {
"name": "tsconfig", "name": "tsconfig",
"version": "0.20.0", "version": "0.21.0",
"private": true, "private": true,
"files": [ "files": [
"base.json", "base.json",

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/types", "name": "@plane/types",
"version": "0.20.0", "version": "0.21.0",
"private": true, "private": true,
"main": "./src/index.d.ts" "main": "./src/index.d.ts"
} }

View File

@ -9,3 +9,15 @@ export type TPaginationInfo = {
per_page?: number; per_page?: number;
total_results: number; total_results: number;
}; };
export type TLogoProps = {
in_use: "emoji" | "icon";
emoji?: {
value?: string;
url?: string;
};
icon?: {
name?: string;
color?: string;
};
};

View File

@ -19,8 +19,8 @@ export interface IInstance {
whitelist_emails: string | undefined; whitelist_emails: string | undefined;
instance_id: string | undefined; instance_id: string | undefined;
license_key: string | undefined; license_key: string | undefined;
api_key: string | undefined; current_version: string | undefined;
version: string | undefined; latest_version: string | undefined;
last_checked_at: string | undefined; last_checked_at: string | undefined;
namespace: string | undefined; namespace: string | undefined;
is_telemetry_enabled: boolean; is_telemetry_enabled: boolean;

View File

@ -57,7 +57,7 @@ export interface INotificationIssueLite {
state_group: string; state_group: string;
} }
export type NotificationType = "created" | "assigned" | "watching" | null; export type NotificationType = "created" | "assigned" | "watching" | "all";
export interface INotificationParams { export interface INotificationParams {
snoozed?: boolean; snoozed?: boolean;

View File

@ -1,3 +1,4 @@
import { TLogoProps } from "./common";
import { EPageAccess } from "./enums"; import { EPageAccess } from "./enums";
export type TPage = { export type TPage = {
@ -17,6 +18,7 @@ export type TPage = {
updated_at: Date | undefined; updated_at: Date | undefined;
updated_by: string | undefined; updated_by: string | undefined;
workspace: string | undefined; workspace: string | undefined;
logo_props: TLogoProps | undefined;
}; };
// page filters // page filters

Some files were not shown because too many files have changed in this diff Show More