commit 3ba092b60c3c93590c138715a7f6a5a4845a91db Author: DCCONSTRUCTIONS Date: Sat Apr 18 18:39:25 2026 +0300 base diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37e4ac9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.DS_Store +plane-app/plane.env +plane-src/node_modules/ +plane-src/.turbo/ +plane-src/.next/ +plane-src/dist/ +plane-src/build/ +plane-src/__pycache__/ +plane-src/.pnpm-store/ +plane-src/apps/web/.react-router/ +plane-src/apps/admin/.react-router/ +plane-src/apps/space/.react-router/ +plane-app/archive/ diff --git a/ARCH_REVIEW_NODEDC_RU.md b/ARCH_REVIEW_NODEDC_RU.md new file mode 100644 index 0000000..58a7e2b --- /dev/null +++ b/ARCH_REVIEW_NODEDC_RU.md @@ -0,0 +1,332 @@ +# Архитектурный обзор Plane CE под NodeDC + +## Короткий вывод + +Как база под локальный PoC и дальнейший форк Plane подходит. + +Причины: +- monorepo прозрачный, фронт и бэк лежат рядом +- backend на Django с довольно прямой моделью данных +- frontend разбит на приложения и пакеты без сильной магии +- роли, проекты, workspace и work items вынесены в явные модели и API +- self-host runtime отделен от исходников, поэтому можно независимо разворачивать стенд и вести форк + +Слабые места: +- есть шероховатости в self-host setup и в некоторых runtime-флоу релиза `v1.3.0` +- часть UI-строк все еще размазана по компонентам, не везде i18n идеально дисциплинирован +- attachment flow в текущем релизе выглядит хрупко + +Что проверено уже на живом локальном инстансе: +- login/onboarding экран +- домашний экран workspace +- project issues/work items +- project views +- project settings +- profile settings + +Фактический остаток после русификации на этих экранах небольшой: +- timezone label `Moscow Time` +- отдельные технические loader/accessibility строки вне основного happy-path +- часть secondary/admin экранов не аудировалась так же глубоко, как основной `apps/web` + +## Как устроен frontend + +Основное web-приложение: +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/web` + +Соседние приложения: +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/admin` +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/space` + +Технологически: +- React +- React Router +- TypeScript +- MobX stores +- Vite/react-router build +- общие UI/утилиты вынесены в workspace packages + +Полезные пакеты: +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/packages/i18n` +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/packages/services` +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/packages/ui` +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/packages/types` +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/packages/shared-state` + +Практически это удобно для форка: +- можно менять только `apps/web`, не трогая backend +- можно переопределять поведение через store/services слой +- локализация и общие UI-слои вынесены отдельно, это удобно для русификации и брендирования под NodeDC + +## Как устроен backend + +Основной backend: +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api` + +Технологически: +- Django +- Django REST style API +- Postgres +- Redis +- RabbitMQ +- MinIO/S3-совместимое хранилище + +Модельный слой читаемый, без сложной скрытой генерации. + +Ключевые модели: +- users: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/user.py` +- workspace: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/workspace.py` +- projects: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/project.py` +- issues/work items: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/issue.py` +- views: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/view.py` +- instance admin: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/license/models/instance.py` + +## Где живет auth / users / roles / projects / work items + +### Auth + +Auth-слой: +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/authentication` + +Что видно на практике: +- есть email/password вход +- есть magic-code и OAuth-провайдеры +- session auth используется в license/admin API +- для локального PoC email/password сценарий нормальный и достаточно простой + +Для NodeDC это плюс: +- можно оставить Plane как внутренний task-сервис +- внешнюю SSO/SSO-агрегацию лучше строить аккуратно через отдельный auth gateway или через ограниченный backend patch, а не переписывать весь auth слой сразу + +### Users + +Основная пользовательская модель: +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/user.py` + +Пользовательские настройки и профили привязаны к workspace/user preference модели, а не размазаны хаотично. + +### Roles + +Роли workspace/project заданы явно числовыми choice-полями. + +Файлы: +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/workspace.py` +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/project.py` + +Базовая ролевая модель: +- `Admin` +- `Member` +- `Guest` + +Это достаточно для PoC, но для NodeDC, если нужны тонкие корпоративные ACL, вероятнее всего придется строить внешний mapping или дополнительный policy layer. + +### Projects + +Файл: +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/project.py` + +Проект содержит нужный минимум: +- имя +- identifier +- network/доступность +- участники +- инвайты +- архивирование + +### Work items / issues + +Файл: +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/api/plane/db/models/issue.py` + +Что важно practically: +- issue-модель богатая +- есть assignees +- comments +- attachments +- labels +- relations +- subscribers +- activity/history +- versions + +Под встроенный task-management контур это хорошая база: предметная модель уже богаче, чем нужен минимальный NodeDC PoC. + +## Что выглядит хорошими точками кастомизации + +### 1. Frontend fork + +Самая безопасная и быстрая зона кастомизации. + +Что удобно менять: +- локализацию +- названия сущностей в UI +- onboarding +- проектные экраны +- навигацию +- виджеты карточек и work item detail +- интеграционные кнопки и ссылки в NodeDC + +Причина: +- это в основном `apps/web` +- изменения изолируются без тяжелого вмешательства в доменную модель +- уже подтверждено практикой на этом стенде: русификация, переименование UI-лейблов и правка hardcoded строк делаются без вмешательства в backend + +### 2. Слой сервисов и stores + +Хорошее место для мягкой интеграции с NodeDC. + +Файлы/пакеты: +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/packages/services` +- store-слой внутри `apps/web/core/store` и `hooks/store` + +Подходит для: +- прокидывания дополнительных данных из NodeDC +- feature-flags +- брендированных сценариев +- дополнительных UI-проверок +- синхронизации preferred language / workspace defaults / видимости пунктов меню + +### 3. Bootstrap / seed / миграции рядом с runtime + +Для PoC и внутренних стендов это удобно держать отдельно от core-кода Plane. + +Что уже сработало: +- отдельный bootstrap-скрипт на Django shell +- demo-данные живут рядом с self-host runtime, а не смешаны с upstream + +## Что лучше не трогать без необходимости + +### 1. Базовый self-host stack целиком + +Не стоит на старте переписывать: +- compose topology +- proxy слой +- очередь/redis/minio wiring + +Причина: +- локальный runtime уже и так неидеален +- при ранней кастомизации инфраструктурного слоя слишком легко получить нестабильный стенд + +### 2. Ядро auth-провайдеров + +Не стоит сразу глубоко влезать в: +- `/apps/api/plane/authentication/provider` +- `/apps/api/plane/authentication/adapter` + +Лучше сначала решить, нужен ли NodeDC-side SSO gateway, либо достаточно controlled login flow. + +### 3. Низкоуровневый attachment/storage flow + +По факту релиза `v1.3.0` это место уже выглядит хрупко. + +Если кастомизировать загрузки и документы, лучше: +- сначала стабилизировать текущий runtime-флоу +- потом отдельно проектировать document-service интеграцию + +Практический вывод по этому пункту: +- для NodeDC-сценария "запрос документов" Plane лучше использовать как UI и контур задач +- документный бинарный контент и его жизненный цикл разумнее держать в отдельном sidecar/document-service + +## Что конфигурируется из коробки + +Из практических вещей без тяжелого форка: +- self-host параметры через `plane.env` +- локальный URL/ports +- storage через MinIO/S3 +- email/auth провайдеры +- язык пользователя +- workspace/project structure +- demo users/projects/issues +- i18n-переводы frontend UI +- локальные image tags для собственного форка frontend/admin/space + +## Что, вероятно, стоит делать отдельным sidecar/service рядом с Plane + +### 1. Корпоративная интеграция с NodeDC-админкой + +Если NodeDC уже источник истины по оргструктуре, сотрудникам и ролям, лучше не вшивать это насмерть в core Plane. + +Практичнее вынести рядом: +- sync-service пользователей +- sync-service оргструктуры / отделов / контуров +- mapping ролей NodeDC -> Plane workspace/project roles + +### 2. Документный контур + +Под сценарий “запросы документов” логично иметь sidecar: +- файловое/документное хранилище +- правила доступа к документам +- интеграцию с существующей NodeDC-документной подсистемой + +Plane можно оставить как task/workflow UI, а не как единственный документный источник истины. + +### 3. Внешние бизнес-правила + +Если понадобятся: +- SLA +- маршруты согласования +- специфические статусы для бухгалтерии/менеджеров +- автоматическое создание задач из событий NodeDC + +Это лучше сначала делать внешним orchestration/service слоем, а не перепахивать core issue lifecycle в Plane. + +## Что можно синхронизировать с NodeDC-админкой + +Наиболее реалистичные направления синхронизации: +- пользователи +- команды / отделы / контуры +- принадлежность к workspace/project +- справочник ролей и роль-mapping +- автосоздание проектов под контуры NodeDC +- начальные шаблоны проектов и saved views +- статусы и labels для типовых процессов вроде бухгалтерии и запросов документов + +## Итог по пригодности под NodeDC + +Для сценария "встроенный task-management контур внутри NodeDC" оценка положительная, если держать реалистичную границу кастомизации: +- Plane как готовое ядро задач, проектов, activity, comments, views и базовых ролей +- NodeDC как источник оргструктуры, корпоративных правил, документов и внешних интеграций + +Нехороший путь: +- пытаться быстро превратить Plane в единственный источник истины по оргструктуре, документам и enterprise-ACL + +Хороший путь: +- форк `apps/web` + ограниченные backend-патчи +- sidecar для sync и document workflows +- постепенное brand/UI-domain подстраивание под NodeDC +- автосоздание work items из событий NodeDC +- ссылки из NodeDC admin в конкретные work items Plane + +## Практическая оценка пригодности под NodeDC + +### Что уже хорошо + +- есть рабочий self-hosted open-source контур +- модель projects/work items уже зрелая +- UI достаточно богат для быстрого PoC +- фронт реально поддается форку и русификации +- предметные сущности хорошо ложатся на сценарии “бухгалтерия / менеджеры / запросы документов” + +### Что сыровато + +- официальный self-host flow не полностью воспроизводим без ручных правок +- attachment/storage часть выглядит ненадежно +- не весь UI идеально дисциплинирован по i18n +- часть внутренних UX-решений все еще ориентирована на общий SaaS-сценарий Plane, а не на embedded B2B-контур + +### Что опасно брать в глубокий форк + +- глубокую переделку auth ядра на первом этапе +- радикальную переделку storage/attachments без отдельного тестового контура +- попытку сделать из Plane master-system для всего NodeDC вместо роли task/workflow сервиса + +## Итог + +Для сценария “встроенный task-management контур внутри NodeDC” Plane CE можно брать как базу под ручную докрутку. + +Но разумная стратегия такая: +- Plane использовать как ядро task/work item UI и workflow-базы +- NodeDC оставить источником истины по оргструктуре и, возможно, идентификации +- сложные корпоративные правила и интеграции выносить в sidecar/sync services рядом + +То есть: как стартовая база для PoC и дальнейшего прикладного форка подходит, как готовый безболезненный enterprise-foundation из коробки — нет. diff --git a/README_RUN_RU.md b/README_RUN_RU.md new file mode 100644 index 0000000..670e3b1 --- /dev/null +++ b/README_RUN_RU.md @@ -0,0 +1,323 @@ +# Plane Community Edition для локального стенда NodeDC + +## Что развернуто + +Локально поднят self-hosted стенд Plane Community Edition для macOS. + +Состав: +- self-host runtime: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-app` +- локальный форк исходников для кастомизации: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src` +- ветка форка: `nodedc-ru-local` +- локальные image tags для UI: `nodedc/plane-frontend:ru`, `nodedc/plane-admin:ru`, `nodedc/plane-space:ru` +- demo bootstrap-скрипт: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/scripts/bootstrap_nodedc_demo.py` +- demo asset для вложения: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/demo-assets/nodedc-document-request-template.txt` + +Используется именно open-source/community self-host путь через официальный `setup.sh`, без enterprise/commercial-фич. + +## Окружение + +- ОС: macOS +- Архитектура: `arm64` (Apple Silicon) +- Shell: `zsh` +- Bash: `/bin/bash` +- Docker: `Docker version 29.1.3` +- Docker Compose: `Docker Compose version v5.0.0-desktop.1` + +## Локальный URL + +- основной URL: `http://localhost:8090` +- без домена и без SSL, как локальный PoC-стенд + +## Где что лежит + +- корень стенда: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER` +- setup script: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/setup.sh` +- compose: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-app/docker-compose.yaml` +- env: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-app/plane.env` +- форк репозитория: `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src` + +## Используемые env-файлы + +Основной env-файл: +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-app/plane.env` + +Ключевые параметры для локалки: +- `WEB_URL=http://localhost:8090` +- `LISTEN_HTTP_PORT=8090` +- `LISTEN_HTTPS_PORT=8443` +- `CORS_ALLOWED_ORIGINS=http://localhost:8090` + +## Команды, которые реально применялись + +### Получение и запуск self-host runtime + +```bash +mkdir -p /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER +curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh +chmod +x setup.sh +./setup.sh install +./setup.sh start +``` + +### Остановка и повторный запуск + +```bash +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER +./setup.sh stop +./setup.sh start +``` + +### Логи + +```bash +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER +./setup.sh logs api +./setup.sh logs worker +./setup.sh logs web +./setup.sh logs admin +./setup.sh logs space +./setup.sh logs proxy +``` + +### Клонирование исходников под форк + +```bash +git clone --depth 1 --branch v1.3.0 https://github.com/makeplane/plane.git /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src +git checkout -b nodedc-ru-local +pnpm install --frozen-lockfile +``` + +### Проверка фронтовых пакетов после русификации + +```bash +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src +pnpm turbo run check:types --filter=web --filter=admin --filter=space --filter='@plane/i18n' --filter='@plane/ui' +pnpm turbo run check:types --filter=web --filter='@plane/i18n' --filter='@plane/utils' +``` + +### Пересборка локального фронта после изменений русификации + +```bash +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src +docker build -t nodedc/plane-frontend:ru -f apps/web/Dockerfile.web . +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER +./setup.sh stop +./setup.sh start +``` + +### Пересборка всех UI-образов после ребрендинга NODE.DC + +```bash +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src +docker build -t nodedc/plane-frontend:ru -f apps/web/Dockerfile.web . +docker build -t nodedc/plane-admin:ru -f apps/admin/Dockerfile.admin . +docker build -t nodedc/plane-space:ru -f apps/space/Dockerfile.space . +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER +./setup.sh stop +./setup.sh start +``` + +### Наполнение demo-данными + +```bash +docker cp /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/demo-assets/nodedc-document-request-template.txt plane-app-api-1:/tmp/nodedc-document-request-template.txt +docker exec -i plane-app-api-1 python manage.py shell < /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/scripts/bootstrap_nodedc_demo.py +``` + +## Команды эксплуатации + +### Старт + +```bash +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER +./setup.sh start +``` + +### Стоп + +```bash +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER +./setup.sh stop +``` + +### Рестарт + +```bash +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER +./setup.sh stop +./setup.sh start +``` + +### Проверка контейнеров + +```bash +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER +./setup.sh status +``` + +Если нужно смотреть контейнеры напрямую: + +```bash +cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-app +docker compose --env-file plane.env ps +``` + +Важно: +- для локального стенда безопаснее пользоваться именно `./setup.sh`, а не голым `docker compose` +- причина: host env может переопределить значения из `plane.env`; на практике это уже ломало `DEBUG` +- если все-таки запускать compose напрямую, лучше делать это с `--env-file plane.env` и без конфликтующих env-переменных в shell + +## Тестовые учетные записи + +Пароль для всех demo-пользователей: + +```text +NodeDC123! +``` + +Пользователи: +- `admin@nodedc.local` +- `accountant@nodedc.local` +- `manager@nodedc.local` +- `docs@nodedc.local` + +## Что создано в demo-данных + +Workspace: +- `NodeDC` (`nodedc`) + +Проекты: +- `Бухгалтерия` (`BUH`) +- `Менеджеры` (`MGR`) +- `Запросы документов` (`DOC`) + +Demo work items: +- адресная задача бухгалтеру +- задача в общий контур менеджеров +- задача на запрос документов +- задача с дедлайном на согласование лимитов +- задача с вложением в проекте запросов документов + +Дополнительно: +- создан сохраненный view `Срочные документы` +- пользователям выставлен язык `ru` + +## Что пришлось чинить вручную + +### 1. Официальный `setup.sh` в актуальном состоянии неустойчиво определял latest release + +Проблема: +- функция `checkLatestRelease()` ожидала формат JSON с пробелом после `tag_name` +- GitHub API отдавал минифицированный JSON +- в результате latest release мог не определяться корректно + +Что сделано: +- локально поправлен парсинг `tag_name` в `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/setup.sh` + +### 2. Хостовый env ломал запуск backend-контейнеров + +Проблема: +- в shell был `DEBUG=release` +- compose-интерполяция подхватывала host env раньше, чем `plane.env` +- `api/worker/migrator` падали с ошибкой преобразования DEBUG в integer + +Что сделано: +- в локальный `setup.sh` добавлена загрузка `plane.env` перед compose-командами +- обертка запускает compose в предсказуемом env-контексте + +### 3. `/api/instances/` может показывать несвежие данные + +Проблема: +- endpoint отдает состояние не всегда синхронно после bootstrap + +Что делалось на практике: +- финальная проверка велась через БД и прикладные API workspace/project/issues + +### 4. Баг на создании attachment в текущем релизе `v1.3.0` + +Проблема: +- legacy flow вложений упирался в `S3Storage.file_overwrite` +- создание attachment через стандартный путь падало + +Что сделано: +- для demo bootstrap файл был загружен напрямую в MinIO через storage API +- после этого вручную создан `FileAsset` для `ISSUE_ATTACHMENT` + +### 5. Onboarding после signup не подходил под локальный контур NodeDC + +Проблема: +- штатный flow после регистрации вел пользователя через `role`, `use case` и `create workspace` +- новые профили создавались backend-ом с `language = en`, поэтому первый onboarding-экран переключался на английский + +Что сделано: +- onboarding в `apps/web` упрощен до одного шага: `Создайте профиль` +- после нажатия `Начать` автоматически создается персональное пустое workspace и пользователь сразу редиректится в него +- backend-дефолт языка для новых профилей изменен на `ru` +- добавлена migration `db.0122_alter_profile_language` + +### 6. Product Tour на главной временно отключен + +Проблема: +- после входа в workspace Plane показывал отдельный welcome/product tour поверх главной страницы +- для локального NodeDC PoC этот оверлей мешает первичному осмотру интерфейса + +Что сделано: +- сам код tour сохранен +- показ временно отключен флагом в `apps/web/core/components/home/root.tsx` +- для возврата достаточно снова включить локальный флаг `IS_PRODUCT_TOUR_ENABLED` +- добавлена migration `db.0123_force_profile_language_ru`, которая переводит все существующие профили на `ru` + +## Русификация + +Русификация делается в локальном форке `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src`. + +Базовый подход: +- использована существующая структура `@plane/i18n` +- `ru` сделан языком по умолчанию +- fallback оставлен `en` +- вынесены и переведены дополнительные строки, которые были захардкожены в web UI +- отдельно локализованы даты и relative-time в `packages/utils/src/datetime.ts` +- локализован дефолтный label группы `All work items` +- живой smoke-test пройден для login, home, project issues, views, project settings и profile settings + +## Ребрендинг NODE.DC + +Что дополнительно изменено поверх русификации: +- пользовательский брендинг `Plane` заменен на `NODE.DC` в основных web/admin/space UI +- логотипы `PlaneLockup` и `PlaneLogo` заменены на asset `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/demo-assets/logo.svg` +- с auth-экрана убраны `Условия использования`, `Политика конфиденциальности` и нижний маркетинговый блок +- из основного web UI убраны GitHub CTA, help/question menu в top bar и badge `Сообщество` внизу sidebar +- после регистрации оставлен только один onboarding-экран `Создайте профиль`; экраны `role`, `use case` и `create workspace` удалены из потока +- product tour на главной странице временно отключен без удаления кода +- для изменений были пересобраны локальные images `nodedc/plane-frontend:ru`, `nodedc/plane-admin:ru`, `nodedc/plane-space:ru` + +Отдельный архитектурный обзор по точкам кастомизации: +- `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/ARCH_REVIEW_NODEDC_RU.md` + +## Повторная инициализация demo-данных + +Если нужно повторно прогнать bootstrap после чистого разворачивания: + +```bash +docker cp /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/demo-assets/nodedc-document-request-template.txt plane-app-api-1:/tmp/nodedc-document-request-template.txt +docker exec -i plane-app-api-1 python manage.py shell < /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/scripts/bootstrap_nodedc_demo.py +``` + +Скрипт сделан как bootstrap для PoC-стенда, а не как production-migration. + +## Практические замечания + +- Это локальный технический стенд, не production. +- Основа подходит для форка фронта и бэка: monorepo прозрачен, модели и API читаемы. +- При следующем этапе кастомизации имеет смысл держать отдельно: + - self-host runtime + - форк `plane-src` + - свои скрипты bootstrap/migrations рядом +- На момент финальной проверки основная пользовательская зона UI уже русифицирована. +- Остаточные англоязычные хвосты, которые еще видны на живом стенде: + - `Moscow Time` в выборе часового пояса проекта + - отдельные loader/accessibility строки вне основных экранов + - часть admin/space интерфейсов не проходила такой же глубокий ручной аудит, как `apps/web` +- Attachment flow релиза `v1.3.0` нестабилен, поэтому demo-вложение создавалось обходным bootstrap-сценарием. diff --git a/demo-assets/logo.svg b/demo-assets/logo.svg new file mode 100644 index 0000000..fa5d68b --- /dev/null +++ b/demo-assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/demo-assets/nodedc-document-request-template.txt b/demo-assets/nodedc-document-request-template.txt new file mode 100644 index 0000000..902680d --- /dev/null +++ b/demo-assets/nodedc-document-request-template.txt @@ -0,0 +1,7 @@ +NodeDC demo attachment + +Checklist: +- contract number +- closing documents +- invoice +- signed акт diff --git a/plane-app/docker-compose.yaml b/plane-app/docker-compose.yaml new file mode 100644 index 0000000..0406ad2 --- /dev/null +++ b/plane-app/docker-compose.yaml @@ -0,0 +1,260 @@ +x-db-env: &db-env + PGHOST: ${PGHOST:-plane-db} + PGDATABASE: ${PGDATABASE:-plane} + POSTGRES_USER: ${POSTGRES_USER:-plane} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-plane} + POSTGRES_DB: ${POSTGRES_DB:-plane} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + PGDATA: ${PGDATA:-/var/lib/postgresql/data} + +x-redis-env: &redis-env + REDIS_HOST: ${REDIS_HOST:-plane-redis} + REDIS_PORT: ${REDIS_PORT:-6379} + REDIS_URL: ${REDIS_URL:-redis://plane-redis:6379/} + +x-minio-env: &minio-env + MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-access-key} + MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-secret-key} + +x-aws-s3-env: &aws-s3-env + AWS_REGION: ${AWS_REGION:-} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-access-key} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-secret-key} + AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} + AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} + +x-proxy-env: &proxy-env + APP_DOMAIN: ${APP_DOMAIN:-localhost} + FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} + CERT_EMAIL: ${CERT_EMAIL} + CERT_ACME_CA: ${CERT_ACME_CA} + CERT_ACME_DNS: ${CERT_ACME_DNS} + LISTEN_HTTP_PORT: ${LISTEN_HTTP_PORT:-80} + LISTEN_HTTPS_PORT: ${LISTEN_HTTPS_PORT:-443} + BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} + SITE_ADDRESS: ${SITE_ADDRESS:-:80} + +x-mq-env: &mq-env # RabbitMQ Settings + RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq} + RABBITMQ_PORT: ${RABBITMQ_PORT:-5672} + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-plane} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-plane} + RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-plane} + RABBITMQ_VHOST: ${RABBITMQ_VHOST:-plane} + +x-live-env: &live-env + API_BASE_URL: ${API_BASE_URL:-http://api:8000} + LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW} + +x-app-env: &app-env + WEB_URL: ${WEB_URL:-http://localhost} + DEBUG: ${DEBUG:-0} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} + GUNICORN_WORKERS: 1 + POSTHOG_API_KEY: ${POSTHOG_API_KEY:-} + POSTHOG_HOST: ${POSTHOG_HOST:-} + INSTANCE_CHANGELOG_URL: ${INSTANCE_CHANGELOG_URL:-} + IS_INTERCOM_ENABLED: ${IS_INTERCOM_ENABLED:-0} + INTERCOM_APP_ID: ${INTERCOM_APP_ID:-} + USE_MINIO: ${USE_MINIO:-1} + DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane} + SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} + AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane} + API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute} + MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0} + LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW} + +services: + web: + image: nodedc/plane-frontend:ru + deploy: + replicas: ${WEB_REPLICAS:-1} + restart_policy: + condition: any + depends_on: + - api + - worker + + space: + image: nodedc/plane-space:ru + deploy: + replicas: ${SPACE_REPLICAS:-1} + restart_policy: + condition: any + depends_on: + - api + - worker + - web + + admin: + image: nodedc/plane-admin:ru + deploy: + replicas: ${ADMIN_REPLICAS:-1} + restart_policy: + condition: any + depends_on: + - api + - web + + live: + image: makeplane/plane-live:${APP_RELEASE:-v1.3.0} + environment: + <<: [*live-env, *redis-env] + deploy: + replicas: ${LIVE_REPLICAS:-1} + restart_policy: + condition: any + depends_on: + - api + - web + + api: + image: nodedc/plane-backend:local + command: ./bin/docker-entrypoint-api.sh + deploy: + replicas: ${API_REPLICAS:-1} + restart_policy: + condition: any + volumes: + - logs_api:/code/plane/logs + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] + depends_on: + - plane-db + - plane-redis + - plane-mq + + worker: + image: nodedc/plane-backend:local + command: ./bin/docker-entrypoint-worker.sh + deploy: + replicas: ${WORKER_REPLICAS:-1} + restart_policy: + condition: any + volumes: + - logs_worker:/code/plane/logs + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] + depends_on: + - api + - plane-db + - plane-redis + - plane-mq + + beat-worker: + image: nodedc/plane-backend:local + command: ./bin/docker-entrypoint-beat.sh + deploy: + replicas: ${BEAT_WORKER_REPLICAS:-1} + restart_policy: + condition: any + volumes: + - logs_beat-worker:/code/plane/logs + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] + depends_on: + - api + - plane-db + - plane-redis + - plane-mq + + migrator: + image: nodedc/plane-backend:local + command: ./bin/docker-entrypoint-migrator.sh + deploy: + replicas: 1 + restart_policy: + condition: on-failure + volumes: + - logs_migrator:/code/plane/logs + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] + depends_on: + - plane-db + - plane-redis + + # Comment this if you already have a database running + plane-db: + image: postgres:15.7-alpine + command: postgres -c 'max_connections=1000' + deploy: + replicas: 1 + restart_policy: + condition: any + environment: + <<: *db-env + volumes: + - pgdata:/var/lib/postgresql/data + + plane-redis: + image: valkey/valkey:7.2.11-alpine + deploy: + replicas: 1 + restart_policy: + condition: any + volumes: + - redisdata:/data + + plane-mq: + image: rabbitmq:3.13.6-management-alpine + deploy: + replicas: 1 + restart_policy: + condition: any + environment: + <<: *mq-env + volumes: + - rabbitmq_data:/var/lib/rabbitmq + + # Comment this if you using any external s3 compatible storage + plane-minio: + image: minio/minio:latest + command: server /export --console-address ":9090" + deploy: + replicas: 1 + restart_policy: + condition: any + environment: + <<: *minio-env + volumes: + - uploads:/export + + # Comment this if you already have a reverse proxy running + proxy: + image: makeplane/plane-proxy:${APP_RELEASE:-v1.3.0} + deploy: + replicas: 1 + restart_policy: + condition: any + environment: + <<: *proxy-env + ports: + - target: 80 + published: ${LISTEN_HTTP_PORT:-80} + protocol: tcp + mode: host + - target: 443 + published: ${LISTEN_HTTPS_PORT:-443} + protocol: tcp + mode: host + volumes: + - proxy_config:/config + - proxy_data:/data + depends_on: + - web + - api + - space + - admin + - live + +volumes: + pgdata: + redisdata: + uploads: + logs_api: + logs_worker: + logs_beat-worker: + logs_migrator: + rabbitmq_data: + proxy_config: + proxy_data: diff --git a/plane-app/plane.env.example b/plane-app/plane.env.example new file mode 100644 index 0000000..16ba3a0 --- /dev/null +++ b/plane-app/plane.env.example @@ -0,0 +1,90 @@ +APP_DOMAIN=localhost +APP_RELEASE=v1.3.0 + +WEB_REPLICAS=1 +SPACE_REPLICAS=1 +ADMIN_REPLICAS=1 +API_REPLICAS=1 +WORKER_REPLICAS=1 +BEAT_WORKER_REPLICAS=1 +LIVE_REPLICAS=1 + +LISTEN_HTTP_PORT=8090 +LISTEN_HTTPS_PORT=8443 + +WEB_URL=http://localhost:8090 +DEBUG=0 +CORS_ALLOWED_ORIGINS=http://localhost:8090 +API_BASE_URL=http://api:8000 + +#DB SETTINGS +PGHOST=plane-db +PGDATABASE=plane +POSTGRES_USER=plane +POSTGRES_PASSWORD=plane +POSTGRES_DB=plane +POSTGRES_PORT=5432 +PGDATA=/var/lib/postgresql/data +DATABASE_URL= + +# REDIS SETTINGS +REDIS_HOST=plane-redis +REDIS_PORT=6379 +REDIS_URL= + +# RabbitMQ Settings +RABBITMQ_HOST=plane-mq +RABBITMQ_PORT=5672 +RABBITMQ_USER=plane +RABBITMQ_PASSWORD=plane +RABBITMQ_VHOST=plane +AMQP_URL= + +# If SSL Cert to be generated, set CERT_EMAIl="email " +CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory +TRUSTED_PROXIES=0.0.0.0/0 +SITE_ADDRESS=:80 +CERT_EMAIL= + + + +# For DNS Challenge based certificate generation, set the CERT_ACME_DNS, CERT_EMAIL +# CERT_ACME_DNS="acme_dns " +CERT_ACME_DNS= + + +# Secret Key +SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5 + +# DATA STORE SETTINGS +USE_MINIO=1 +AWS_REGION= +AWS_ACCESS_KEY_ID=access-key +AWS_SECRET_ACCESS_KEY=secret-key +AWS_S3_ENDPOINT_URL=http://plane-minio:9000 +AWS_S3_BUCKET_NAME=uploads +FILE_SIZE_LIMIT=5242880 +POSTHOG_API_KEY= +POSTHOG_HOST= +INSTANCE_CHANGELOG_URL= +IS_INTERCOM_ENABLED=0 +INTERCOM_APP_ID= + +# Gunicorn Workers +GUNICORN_WORKERS=1 + +# UNCOMMENT `DOCKER_PLATFORM` IF YOU ARE ON `ARM64` AND DOCKER IMAGE IS NOT AVAILABLE FOR RESPECTIVE `APP_RELEASE` +# DOCKER_PLATFORM=linux/amd64 + +# Force HTTPS for handling SSL Termination +MINIO_ENDPOINT_SSL=0 + +# API key rate limit +API_KEY_RATE_LIMIT=60/minute + +# Live server environment variables +# WARNING: You must set a secure value for LIVE_SERVER_SECRET_KEY in production environments. +LIVE_SERVER_SECRET_KEY= +DOCKERHUB_USER=makeplane +PULL_POLICY=if_not_present +CUSTOM_BUILD=false diff --git a/plane-src/.dockerignore b/plane-src/.dockerignore new file mode 100644 index 0000000..e0b681f --- /dev/null +++ b/plane-src/.dockerignore @@ -0,0 +1,69 @@ +.git +*.pyc +.env +venv +.venv +node_modules/ +**/node_modules/ +npm-debug.log +.next/ +**/.next/ +.turbo/ +**/.turbo/ +build/ +**/build/ +out/ +**/out/ +dist/ +**/dist/ +# Logs +npm-debug.log* +pnpm-debug.log* +.pnpm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS junk +.DS_Store +Thumbs.db + +# Editor settings +.vscode +.idea + +# Coverage and test output +coverage/ +**/coverage/ +*.lcov +.junit/ +test-results/ + +# Caches and build artifacts +.cache/ +**/.cache/ +storybook-static/ +*storybook.log +*.tsbuildinfo + +# Local env and secrets +.env.local +.env.development.local +.env.test.local +.env.production.local +.secrets +tmp/ +temp/ + +# Database/cache dumps +*.rdb +*.rdb.gz + +# Misc +*.pem +*.key + +# React Router - https://github.com/remix-run/react-router-templates/blob/dc79b1a065f59f3bfd840d4ef75cc27689b611e6/default/.dockerignore +.react-router/ +build/ +node_modules/ +README.md diff --git a/plane-src/.env.example b/plane-src/.env.example new file mode 100644 index 0000000..90efa8b --- /dev/null +++ b/plane-src/.env.example @@ -0,0 +1,56 @@ +# Database Settings +POSTGRES_USER="plane" +POSTGRES_PASSWORD="plane" +POSTGRES_DB="plane" +PGDATA="/var/lib/postgresql/data" + +# Redis Settings +REDIS_HOST="plane-redis" +REDIS_PORT="6379" + +# RabbitMQ Settings +RABBITMQ_HOST="plane-mq" +RABBITMQ_PORT="5672" +RABBITMQ_USER="plane" +RABBITMQ_PASSWORD="plane" +RABBITMQ_VHOST="plane" + +LISTEN_HTTP_PORT=80 +LISTEN_HTTPS_PORT=443 + +# AWS Settings +AWS_REGION="" +AWS_ACCESS_KEY_ID="access-key" +AWS_SECRET_ACCESS_KEY="secret-key" +AWS_S3_ENDPOINT_URL="http://plane-minio:9000" +# Changing this requires change in the proxy config for uploads if using minio setup +AWS_S3_BUCKET_NAME="uploads" +# Maximum file upload limit +FILE_SIZE_LIMIT=5242880 + +# GPT settings +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated + +# Settings related to Docker +DOCKERIZED=1 # deprecated + +# set to 1 If using the pre-configured minio setup +USE_MINIO=1 + +# If SSL Cert to be generated, set CERT_EMAIl="email " +CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory +TRUSTED_PROXIES=0.0.0.0/0 +SITE_ADDRESS=:80 +CERT_EMAIL= + +# For DNS Challenge based certificate generation, set the CERT_ACME_DNS, CERT_EMAIL +# CERT_ACME_DNS="acme_dns " +CERT_ACME_DNS= + +# Force HTTPS for handling SSL Termination +MINIO_ENDPOINT_SSL=0 + +# API key rate limit +API_KEY_RATE_LIMIT="60/minute" diff --git a/plane-src/.gitattributes b/plane-src/.gitattributes new file mode 100644 index 0000000..526c8a3 --- /dev/null +++ b/plane-src/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf \ No newline at end of file diff --git a/plane-src/.github/ISSUE_TEMPLATE/--bug-report.yaml b/plane-src/.github/ISSUE_TEMPLATE/--bug-report.yaml new file mode 100644 index 0000000..277a3bd --- /dev/null +++ b/plane-src/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -0,0 +1,73 @@ +name: Bug report +description: Create a bug report to help us improve Plane +title: "[bug]: " +labels: [🐛bug, plane] +assignees: [vihar, pushya22] +body: +- type: markdown + attributes: + value: | + Thank you for taking the time to fill out this bug report. +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Current behavior + description: A concise description of what you're experiencing and what you expect + placeholder: | + When I do , happens and I see the error message attached below: + ```...``` + What I expect is + validations: + required: true +- type: textarea + attributes: + label: Steps to reproduce + description: Add steps to reproduce this behaviour, include console or network logs and screenshots + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true +- type: dropdown + id: env + attributes: + label: Environment + options: + - Production + - Deploy preview + validations: + required: true +- type: dropdown + id: browser + attributes: + label: Browser + options: + - Google Chrome + - Mozilla Firefox + - Safari + - Other +- type: dropdown + id: variant + attributes: + label: Variant + options: + - Cloud + - Self-hosted + - Local + validations: + required: true +- type: input + id: version + attributes: + label: Version + placeholder: v0.17.0-dev + validations: + required: true \ No newline at end of file diff --git a/plane-src/.github/ISSUE_TEMPLATE/--feature-request.yaml b/plane-src/.github/ISSUE_TEMPLATE/--feature-request.yaml new file mode 100644 index 0000000..c2bd609 --- /dev/null +++ b/plane-src/.github/ISSUE_TEMPLATE/--feature-request.yaml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest a feature to improve Plane +title: "[feature]: " +labels: [✨feature, plane] +assignees: [vihar, pushya22] +body: +- type: markdown + attributes: + value: | + Thank you for taking the time to request a feature for Plane +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue related to this feature request already exists + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Summary + description: One paragraph description of the feature + validations: + required: true +- type: textarea + attributes: + label: Why should this be worked on? + description: A concise description of the problems or use cases for this feature request + validations: + required: true \ No newline at end of file diff --git a/plane-src/.github/ISSUE_TEMPLATE/config.yaml b/plane-src/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..301080b --- /dev/null +++ b/plane-src/.github/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,6 @@ +contact_links: + - name: Help and support + about: Reach out to us on our Forum or GitHub discussions. + - name: Dedicated support + url: mailto:support@plane.so + about: Write to us if you'd like dedicated support using Plane diff --git a/plane-src/.github/instructions/bash.instructions.md b/plane-src/.github/instructions/bash.instructions.md new file mode 100644 index 0000000..b14d3c9 --- /dev/null +++ b/plane-src/.github/instructions/bash.instructions.md @@ -0,0 +1,48 @@ +--- +description: Guidelines for bash commands and tooling in the monorepo +applyTo: "**/*.sh" +--- + +# Bash & Tooling Instructions + +This document outlines the standard tools and commands used in this monorepo. + +## Package Manager + +We use **pnpm** for package management. +- **Do not use `npm` or `yarn`.** +- Lockfile: `pnpm-lock.yaml` +- Workspace configuration: `pnpm-workspace.yaml` + +### Common Commands +- Install dependencies: `pnpm install` +- Run a script in a specific package: `pnpm --filter run + + + + diff --git a/plane-src/apps/api/templates/csrf_failure.html b/plane-src/apps/api/templates/csrf_failure.html new file mode 100644 index 0000000..b5a58cb --- /dev/null +++ b/plane-src/apps/api/templates/csrf_failure.html @@ -0,0 +1,66 @@ + + + + + + + CSRF Verification Failed + + + +
+
+

CSRF Verification Failed

+
+
+

+ It looks like your form submission has expired or there was a problem + with your request. +

+

Please try the following:

+
    +
  • Refresh the page and try submitting the form again.
  • +
  • Ensure that cookies are enabled in your browser.
  • +
+ Go to Home Page +
+
+ + diff --git a/plane-src/apps/api/templates/emails/auth/forgot_password.html b/plane-src/apps/api/templates/emails/auth/forgot_password.html new file mode 100644 index 0000000..29c9b46 --- /dev/null +++ b/plane-src/apps/api/templates/emails/auth/forgot_password.html @@ -0,0 +1,306 @@ + + + + + + Reset your Plane password + + + + + + + + Reset your Plane password with this secure link. + + + + + + + + \ No newline at end of file diff --git a/plane-src/apps/api/templates/emails/auth/magic_signin.html b/plane-src/apps/api/templates/emails/auth/magic_signin.html new file mode 100644 index 0000000..a52700f --- /dev/null +++ b/plane-src/apps/api/templates/emails/auth/magic_signin.html @@ -0,0 +1,265 @@ + + + + + + Your Plane login code + + + + + + + + Your Plane login code {{code}} is valid for 10 minutes. + + + + + + + + \ No newline at end of file diff --git a/plane-src/apps/api/templates/emails/exports/analytics.html b/plane-src/apps/api/templates/emails/exports/analytics.html new file mode 100644 index 0000000..f9a250d --- /dev/null +++ b/plane-src/apps/api/templates/emails/exports/analytics.html @@ -0,0 +1,215 @@ + + + + + + Your Plane Analytics export is ready + + + + + + + + Your requested Plane Analytics data export is attached as a CSV file. + + + + + + + + \ No newline at end of file diff --git a/plane-src/apps/api/templates/emails/invitations/project_invitation.html b/plane-src/apps/api/templates/emails/invitations/project_invitation.html new file mode 100644 index 0000000..36aecd6 --- /dev/null +++ b/plane-src/apps/api/templates/emails/invitations/project_invitation.html @@ -0,0 +1,349 @@ + + + + + + + + {{ first_name }} invited you to join {{ project_name }} on Plane + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/api/templates/emails/invitations/workspace_invitation.html b/plane-src/apps/api/templates/emails/invitations/workspace_invitation.html new file mode 100644 index 0000000..84ba654 --- /dev/null +++ b/plane-src/apps/api/templates/emails/invitations/workspace_invitation.html @@ -0,0 +1,306 @@ + + + + + + {{first_name}} invited you to {{workspace_name}} on Plane + + + + + + + + {{first_name}} has invited you to join {{workspace_name}} on Plane. + + + + + + + + \ No newline at end of file diff --git a/plane-src/apps/api/templates/emails/notifications/issue-updates.html b/plane-src/apps/api/templates/emails/notifications/issue-updates.html new file mode 100644 index 0000000..c6fe3b2 --- /dev/null +++ b/plane-src/apps/api/templates/emails/notifications/issue-updates.html @@ -0,0 +1,243 @@ + + + + + + Updates on {{entity_type}} + + + + + +
+ +
+ + + + +
+
+
+
+ +
+
+ + + + +
+

{{ issue.issue_identifier }} updates

+

{{workspace}}/{{project}}/{{issue.issue_identifier}}: {{ issue.name }}

+
+
+ {% if actors_involved == 1 %} +

{{summary}} {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %} .

+ {% else %} +

{{summary}} {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %} and others.

+ {% endif %} {% for update in data %} {% if update.changes.name %} +

The {{entity_type}} title has been updated to {{ issue.name}}

+ {% endif %} {% if data %} +
+ +
+

Updates

+
+ +
+ + + + + + + +
+ {% if update.actor_detail.avatar_url %} {% else %} + + + + +
{{ update.actor_detail.first_name.0 }}
+ {% endif %} +
+

{{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }}

+
+

{{ update.activity_time }}

+
+ {% if update.changes.target_date %} + + + + + + +
+
+

Due Date:

+
+
+ {% if update.changes.target_date.new_value.0 %} +

{{ update.changes.target_date.new_value.0 }}

+ {% else %} +

{{ update.changes.target_date.old_value.0 }}

+ {% endif %} +
+ {% endif %} {% if update.changes.duplicate %} + + + + {% if update.changes.duplicate.new_value.0 %} + + {% endif %} {% if update.changes.duplicate.new_value.2 %} + + {% endif %} {% if update.changes.duplicate.old_value.0 %} + + {% endif %} {% if update.changes.duplicate.old_value.2 %} + + {% endif %} + +
Duplicate: {% for duplicate in update.changes.duplicate.new_value|slice:":2" %} {{ duplicate }} {% endfor %} +{{ update.changes.duplicate.new_value|length|add:"-2" }} more {% for duplicate in update.changes.duplicate.old_value|slice:":2" %} {{ duplicate }} {% endfor %} +{{ update.changes.duplicate.old_value|length|add:"-2" }} more
+ {% endif %} {% if update.changes.assignees %} + + + + + +
Assignee: {% if update.changes.assignees.new_value.0 %} {{update.changes.assignees.new_value.0}} {% endif %} {% if update.changes.assignees.new_value.1 %} +{{ update.changes.assignees.new_value|length|add:"-1"}} more {% endif %} {% if update.changes.assignees.old_value.0 %} {{update.changes.assignees.old_value.0}} {% endif %} {% if update.changes.assignees.old_value.1 %} +{{ update.changes.assignees.old_value|length|add:"-1"}} more {% endif %}
+ {% endif %} {% if update.changes.labels %} + + + + + +
Labels: {% if update.changes.labels.new_value.0 %} {{update.changes.labels.new_value.0}} {% endif %} {% if update.changes.labels.new_value.1 %} +{{ update.changes.labels.new_value|length|add:"-1"}} more {% endif %} {% if update.changes.labels.old_value.0 %} {{update.changes.labels.old_value.0}} {% endif %} {% if update.changes.labels.old_value.1 %} +{{ update.changes.labels.old_value|length|add:"-1"}} more {% endif %}
+ {% endif %} {% if update.changes.state %} + + + + + {% if update.changes.state.old_value.0 == 'Backlog' or update.changes.state.old_value.0 == 'In Progress' or update.changes.state.old_value.0 == 'Done' or update.changes.state.old_value.0 == 'Cancelled' %} + + {% endif %} + + + {% if update.changes.state.new_value|last == 'Backlog' or update.changes.state.new_value|last == 'In Progress' or update.changes.state.new_value|last == 'Done' or update.changes.state.new_value|last == 'Cancelled' %} + + {% endif %} + + +
+

State:

+
+

{{ update.changes.state.old_value.0 }}

+
+

{{update.changes.state.new_value|last }}

+
+ {% endif %} {% if update.changes.link %} + + + + + + +
+

Links:

+
+ {% for link in update.changes.link.new_value %} {{ link }} {% endfor %} {% if update.changes.link.old_value|length > 0 %} {% if update.changes.link.old_value.0 != "None" %} +

2 Links were removed

+ {% endif %} {% endif %} +
+ {% endif %} {% if update.changes.priority %} + + + + + + + + +
+

Priority:

+
+

{{ update.changes.priority.old_value.0 }}

+
+

{{ update.changes.priority.new_value|last }}

+
+ {% endif %} {% if update.changes.blocking.new_value %} + + + + {% if update.changes.blocking.new_value.0 %} + + {% endif %} {% if update.changes.blocking.new_value.2 %} + + {% endif %} {% if update.changes.blocking.old_value.0 %} + + {% endif %} {% if update.changes.blocking.old_value.2 %} + + {% endif %} + +
Blocking: {% for blocking in update.changes.blocking.new_value|slice:":2" %} {{ blocking }} {% endfor %} +{{ update.changes.blocking.new_value|length|add:"-2" }} more {% for blocking in update.changes.blocking.old_value|slice:":2" %} {{ blocking }} {% endfor %} +{{ update.changes.blocking.old_value|length|add:"-2" }} more
+ {% endif %} +
+
+ {% endif %} {% endfor %} {% if comments.0 %} +
+ +

Comments

+ {% for comment in comments %} + + + + + +
+ {% if comment.actor_detail.avatar_url %} {% else %} + + + + +
{{ comment.actor_detail.first_name.0 }}
+ {% endif %} +
+ + + + + {% for actor_comment in comment.actor_comments.new_value %} + + + + {% endfor %} +
+

{{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }}

+
+
+

{{ actor_comment|safe }}

+
+
+
+ {% endfor %} +
+ {% endif %} +
+ +
View {{entity_type}}
+
+
+ + + + + +
+
+ This email was sent to {{ receiver.email }}. If you'd rather not receive this kind of email, you can unsubscribe to the {{entity_type}} or manage your email preferences. + +
+
+
+ + \ No newline at end of file diff --git a/plane-src/apps/api/templates/emails/notifications/project_addition.html b/plane-src/apps/api/templates/emails/notifications/project_addition.html new file mode 100644 index 0000000..bc779f5 --- /dev/null +++ b/plane-src/apps/api/templates/emails/notifications/project_addition.html @@ -0,0 +1,307 @@ + + + + + + {{inviter_first_name}} invited you to {{project_name}} on Plane + + + + + + + + {{inviter_first_name}} has invited you to the {{project_name}} project in + {{workspace_name}} on Plane. + + + + + + + + diff --git a/plane-src/apps/api/templates/emails/notifications/webhook-deactivate.html b/plane-src/apps/api/templates/emails/notifications/webhook-deactivate.html new file mode 100644 index 0000000..44aca67 --- /dev/null +++ b/plane-src/apps/api/templates/emails/notifications/webhook-deactivate.html @@ -0,0 +1,298 @@ + + + + + + + + {{ message }} + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plane-src/apps/api/templates/emails/test_email.html b/plane-src/apps/api/templates/emails/test_email.html new file mode 100644 index 0000000..4e4d3d9 --- /dev/null +++ b/plane-src/apps/api/templates/emails/test_email.html @@ -0,0 +1,6 @@ + + +

This is a test email sent to verify if email configuration is working as expected in your Plane instance.

+ +

Regards,
Team Plane

+ \ No newline at end of file diff --git a/plane-src/apps/api/templates/emails/user/email_updated.html b/plane-src/apps/api/templates/emails/user/email_updated.html new file mode 100644 index 0000000..cd13347 --- /dev/null +++ b/plane-src/apps/api/templates/emails/user/email_updated.html @@ -0,0 +1,1058 @@ + + + + + + + + Plane email address successfully updated + + + + + + + + + + + + + + diff --git a/plane-src/apps/api/templates/emails/user/user_activation.html b/plane-src/apps/api/templates/emails/user/user_activation.html new file mode 100644 index 0000000..8e0a692 --- /dev/null +++ b/plane-src/apps/api/templates/emails/user/user_activation.html @@ -0,0 +1,1570 @@ + + + + + + + + Your Plane account is now active + + + + + + + + + + + + + + + diff --git a/plane-src/apps/api/templates/emails/user/user_deactivation.html b/plane-src/apps/api/templates/emails/user/user_deactivation.html new file mode 100644 index 0000000..f5e8718 --- /dev/null +++ b/plane-src/apps/api/templates/emails/user/user_deactivation.html @@ -0,0 +1,1571 @@ + + + + + + + + Your Plane account has been deactivated + + + + + + + + + + + + + + + diff --git a/plane-src/apps/live/.env.example b/plane-src/apps/live/.env.example new file mode 100644 index 0000000..5fc90d7 --- /dev/null +++ b/plane-src/apps/live/.env.example @@ -0,0 +1,14 @@ +PORT=3100 +API_BASE_URL="http://localhost:8000" + +WEB_BASE_URL="http://localhost:3000" + +LIVE_BASE_URL="http://localhost:3100" +LIVE_BASE_PATH="/live" + +LIVE_SERVER_SECRET_KEY="secret-key" + +# If you prefer not to provide a Redis URL, you can set the REDIS_HOST and REDIS_PORT environment variables instead. +REDIS_PORT=6379 +REDIS_HOST=localhost +REDIS_URL="redis://localhost:6379/" diff --git a/plane-src/apps/live/.prettierignore b/plane-src/apps/live/.prettierignore new file mode 100644 index 0000000..b0b8bc6 --- /dev/null +++ b/plane-src/apps/live/.prettierignore @@ -0,0 +1,10 @@ +.next/ +.react-router/ +.turbo/ +.vite/ +build/ +dist/ +node_modules/ +out/ +pnpm-lock.yaml +storybook-static/ diff --git a/plane-src/apps/live/Dockerfile.dev b/plane-src/apps/live/Dockerfile.dev new file mode 100644 index 0000000..5e0f537 --- /dev/null +++ b/plane-src/apps/live/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM node:22-alpine + +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY . . +RUN corepack enable pnpm && pnpm add -g turbo +RUN pnpm install +EXPOSE 3003 + +ENV TURBO_TELEMETRY_DISABLED=1 + +VOLUME [ "/app/node_modules", "/app/live/node_modules"] + +CMD ["pnpm","dev", "--filter=live"] diff --git a/plane-src/apps/live/Dockerfile.live b/plane-src/apps/live/Dockerfile.live new file mode 100644 index 0000000..801afca --- /dev/null +++ b/plane-src/apps/live/Dockerfile.live @@ -0,0 +1,67 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-alpine AS base + +# Setup pnpm package manager with corepack and configure global bin directory for caching +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +# ***************************************************************************** +# STAGE 1: Prune the project +# ***************************************************************************** +FROM base AS builder +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk update +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app +ARG TURBO_VERSION=2.9.4 +RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION} +COPY . . +RUN turbo prune --scope=live --docker + +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** +# Add lockfile and package.json's of isolated subworkspace +FROM base AS installer +RUN apk update +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# First install dependencies (as they change less often) +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN corepack enable pnpm + +# Copy full directory structure before fetch to ensure all package.json files are available +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +# Fetch dependencies to cache store, then install offline with dev deps +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store + +ENV TURBO_TELEMETRY_DISABLED=1 + +RUN pnpm turbo run build --filter=live + +# ***************************************************************************** +# STAGE 3: Run the project +# ***************************************************************************** + +FROM base AS runner +WORKDIR /app + +COPY --from=installer /app/packages ./packages +COPY --from=installer /app/apps/live/dist ./apps/live/dist +COPY --from=installer /app/apps/live/node_modules ./apps/live/node_modules +COPY --from=installer /app/node_modules ./node_modules +COPY --from=installer /app/apps/live/package.json ./apps/live/package.json + +ENV TURBO_TELEMETRY_DISABLED=1 + +EXPOSE 3000 + +CMD ["node", "apps/live"] diff --git a/plane-src/apps/live/package.json b/plane-src/apps/live/package.json new file mode 100644 index 0000000..1570ed9 --- /dev/null +++ b/plane-src/apps/live/package.json @@ -0,0 +1,80 @@ +{ + "name": "live", + "version": "1.3.0", + "private": true, + "description": "A realtime collaborative server powers Plane's rich text editor", + "license": "AGPL-3.0", + "author": "Plane Software Inc.", + "type": "module", + "main": "./dist/start.mjs", + "module": "./dist/start.mjs", + "exports": { + ".": "./dist/start.mjs", + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsc --noEmit && tsdown", + "dev": "tsdown --watch --no-clean --onSuccess \"node --env-file=.env .\"", + "start": "node --env-file=.env .", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "check:lint": "oxlint --max-warnings=119 .", + "check:types": "tsc --noEmit", + "check:format": "oxfmt --check .", + "fix:lint": "oxlint --fix .", + "fix:format": "oxfmt .", + "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist" + }, + "dependencies": { + "@effect/platform": "^0.94.0", + "@effect/platform-node": "^0.104.0", + "@fontsource/inter": "5.2.8", + "@hocuspocus/extension-database": "2.15.2", + "@hocuspocus/extension-logger": "2.15.2", + "@hocuspocus/extension-redis": "2.15.2", + "@hocuspocus/server": "2.15.2", + "@hocuspocus/transformer": "2.15.2", + "@plane/decorators": "workspace:*", + "@plane/editor": "workspace:*", + "@plane/logger": "workspace:*", + "@plane/types": "workspace:*", + "@react-pdf/renderer": "^4.3.0", + "@react-pdf/types": "^2.9.2", + "@tiptap/core": "catalog:", + "@tiptap/html": "catalog:", + "axios": "catalog:", + "compression": "1.8.1", + "cors": "^2.8.5", + "dotenv": "catalog:", + "effect": "3.20.0", + "express": "catalog:", + "express-ws": "^5.0.2", + "helmet": "^7.1.0", + "ioredis": "5.7.0", + "react": "catalog:", + "sharp": "^0.34.3", + "uuid": "catalog:", + "ws": "^8.18.3", + "y-prosemirror": "^1.3.7", + "y-protocols": "^1.0.6", + "yjs": "^13.6.20", + "zod": "^3.25.76" + }, + "devDependencies": { + "@plane/typescript-config": "workspace:*", + "@types/compression": "1.8.1", + "@types/cors": "^2.8.17", + "@types/express": "4.17.23", + "@types/express-ws": "^3.0.5", + "@types/node": "catalog:", + "@types/pdf-parse": "^1.1.5", + "@types/react": "catalog:", + "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.0.8", + "pdf-parse": "^2.4.5", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "^4.0.8" + } +} diff --git a/plane-src/apps/live/src/controllers/collaboration.controller.ts b/plane-src/apps/live/src/controllers/collaboration.controller.ts new file mode 100644 index 0000000..92ead80 --- /dev/null +++ b/plane-src/apps/live/src/controllers/collaboration.controller.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Hocuspocus } from "@hocuspocus/server"; +import type { Request } from "express"; +import type WebSocket from "ws"; +// plane imports +import { Controller, WebSocket as WSDecorator } from "@plane/decorators"; +import { logger } from "@plane/logger"; + +@Controller("/collaboration") +export class CollaborationController { + [key: string]: unknown; + private readonly hocusPocusServer: Hocuspocus; + + constructor(hocusPocusServer: Hocuspocus) { + this.hocusPocusServer = hocusPocusServer; + } + + @WSDecorator("/") + handleConnection(ws: WebSocket, req: Request) { + try { + // Initialize the connection with Hocuspocus + this.hocusPocusServer.handleConnection(ws, req); + + // Set up error handling for the connection + ws.on("error", (error: Error) => { + logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error); + ws.close(1011, "Internal server error"); + }); + } catch (error) { + logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error); + ws.close(1011, "Internal server error"); + } + } +} diff --git a/plane-src/apps/live/src/controllers/document.controller.ts b/plane-src/apps/live/src/controllers/document.controller.ts new file mode 100644 index 0000000..3a0282f --- /dev/null +++ b/plane-src/apps/live/src/controllers/document.controller.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Request, Response } from "express"; +import { z } from "zod"; +// helpers +import { Controller, Post } from "@plane/decorators"; +import { convertHTMLDocumentToAllFormats } from "@plane/editor"; +// logger +import { logger } from "@plane/logger"; +import type { TConvertDocumentRequestBody } from "@/types"; + +// Define the schema with more robust validation +const convertDocumentSchema = z.object({ + description_html: z + .string() + .min(1, "HTML content cannot be empty") + .refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace") + .refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"), + variant: z.enum(["rich", "document"]), +}); + +@Controller("/convert-document") +export class DocumentController { + @Post("/") + async convertDocument(req: Request, res: Response) { + try { + // Validate request body + const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody); + const { description_html, variant } = validatedData; + + // Process document conversion + const { description_json, description_binary } = convertHTMLDocumentToAllFormats({ + document_html: description_html, + variant, + }); + + // Return successful response + res.status(200).json({ + description_json, + description_binary, + }); + } catch (error) { + if (error instanceof z.ZodError) { + const validationErrors = error.errors.map((err) => ({ + path: err.path.join("."), + message: err.message, + })); + logger.error("DOCUMENT_CONTROLLER: Validation error", { + validationErrors, + }); + return res.status(400).json({ + message: `Validation error`, + context: { + validationErrors, + }, + }); + } else { + logger.error("DOCUMENT_CONTROLLER: Internal server error", error); + return res.status(500).json({ + message: `Internal server error.`, + }); + } + } + } +} diff --git a/plane-src/apps/live/src/controllers/health.controller.ts b/plane-src/apps/live/src/controllers/health.controller.ts new file mode 100644 index 0000000..463d986 --- /dev/null +++ b/plane-src/apps/live/src/controllers/health.controller.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Request, Response } from "express"; +import { Controller, Get } from "@plane/decorators"; +import { env } from "@/env"; + +@Controller("/health") +export class HealthController { + @Get("/") + async healthCheck(_req: Request, res: Response) { + res.status(200).json({ + status: "OK", + timestamp: new Date().toISOString(), + version: env.APP_VERSION, + }); + } +} diff --git a/plane-src/apps/live/src/controllers/index.ts b/plane-src/apps/live/src/controllers/index.ts new file mode 100644 index 0000000..2ae3bce --- /dev/null +++ b/plane-src/apps/live/src/controllers/index.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { CollaborationController } from "./collaboration.controller"; +import { DocumentController } from "./document.controller"; +import { HealthController } from "./health.controller"; +import { PdfExportController } from "./pdf-export.controller"; + +export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController]; diff --git a/plane-src/apps/live/src/controllers/pdf-export.controller.ts b/plane-src/apps/live/src/controllers/pdf-export.controller.ts new file mode 100644 index 0000000..34c03d2 --- /dev/null +++ b/plane-src/apps/live/src/controllers/pdf-export.controller.ts @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Request, Response } from "express"; +import { Effect, Schema, Cause } from "effect"; +import { Controller, Post } from "@plane/decorators"; +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +import { PdfExportRequestBody, PdfValidationError, PdfAuthenticationError } from "@/schema/pdf-export"; +import { PdfExportService, exportToPdf } from "@/services/pdf-export"; +import type { PdfExportInput } from "@/services/pdf-export"; + +@Controller("/pdf-export") +export class PdfExportController { + /** + * Parses and validates the request, returning a typed input object + */ + private parseRequest( + req: Request, + requestId: string + ): Effect.Effect { + return Effect.gen(function* () { + const cookie = req.headers.cookie || ""; + if (!cookie) { + return yield* Effect.fail( + new PdfAuthenticationError({ + message: "Authentication required", + }) + ); + } + + const body = yield* Schema.decodeUnknown(PdfExportRequestBody)(req.body).pipe( + Effect.mapError( + (cause) => + new PdfValidationError({ + message: "Invalid request body", + cause, + }) + ) + ); + + return { + pageId: body.pageId, + workspaceSlug: body.workspaceSlug, + projectId: body.projectId, + title: body.title, + author: body.author, + subject: body.subject, + pageSize: body.pageSize, + pageOrientation: body.pageOrientation, + fileName: body.fileName, + noAssets: body.noAssets, + cookie, + requestId, + }; + }); + } + + /** + * Maps domain errors to HTTP responses + */ + private mapErrorToHttpResponse(error: unknown): { status: number; error: string } { + if (error && typeof error === "object" && "_tag" in error) { + const tag = (error as { _tag: string })._tag; + const message = (error as { message?: string }).message || "Unknown error"; + + switch (tag) { + case "PdfValidationError": + return { status: 400, error: message }; + case "PdfAuthenticationError": + return { status: 401, error: message }; + case "PdfContentFetchError": + return { + status: message.includes("not found") ? 404 : 502, + error: message, + }; + case "PdfTimeoutError": + return { status: 504, error: message }; + case "PdfGenerationError": + return { status: 500, error: message }; + case "PdfMetadataFetchError": + case "PdfImageProcessingError": + return { status: 502, error: message }; + default: + return { status: 500, error: message }; + } + } + return { status: 500, error: "Failed to generate PDF" }; + } + + @Post("/") + async exportToPdf(req: Request, res: Response) { + const requestId = crypto.randomUUID(); + + const effect = Effect.gen(this, function* () { + // Parse request + const input = yield* this.parseRequest(req, requestId); + + // Delegate to service + return yield* exportToPdf(input); + }).pipe( + // Log errors before catching them + Effect.tapError((error) => Effect.logError("PDF_EXPORT: Export failed", { requestId, error })), + // Map all tagged errors to HTTP responses + Effect.catchAll((error) => Effect.succeed(this.mapErrorToHttpResponse(error))), + // Handle unexpected defects + Effect.catchAllDefect((defect) => { + const appError = new AppError(Cause.pretty(Cause.die(defect)), { + context: { requestId, operation: "exportToPdf" }, + }); + logger.error("PDF_EXPORT: Unexpected failure", appError); + return Effect.succeed({ status: 500, error: "Failed to generate PDF" }); + }) + ); + + const result = await Effect.runPromise(Effect.provide(effect, PdfExportService.Default)); + + // Check if result is an error response + if ("error" in result && "status" in result) { + return res.status(result.status).json({ message: result.error }); + } + + // Success - send PDF + const { pdfBuffer, outputFileName } = result; + + // Sanitize filename for Content-Disposition header to prevent header injection + const sanitizedFileName = outputFileName + .replace(/["\\\r\n]/g, "") // Remove quotes, backslashes, and CRLF + .replace(/[^\x20-\x7E]/g, "_"); // Replace non-ASCII with underscore + + res.setHeader("Content-Type", "application/pdf"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${sanitizedFileName}"; filename*=UTF-8''${encodeURIComponent(outputFileName)}` + ); + res.setHeader("Content-Length", pdfBuffer.length); + return res.send(pdfBuffer); + } +} diff --git a/plane-src/apps/live/src/env.ts b/plane-src/apps/live/src/env.ts new file mode 100644 index 0000000..c9b61bd --- /dev/null +++ b/plane-src/apps/live/src/env.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import * as dotenv from "dotenv"; +import { z } from "zod"; + +dotenv.config(); + +// Environment variable validation +const envSchema = z.object({ + APP_VERSION: z.string().default("1.0.0"), + HOSTNAME: z.string().optional(), + PORT: z.string().default("3000"), + API_BASE_URL: z.string().url("API_BASE_URL must be a valid URL"), + // CORS configuration + CORS_ALLOWED_ORIGINS: z.string().default(""), + // Live running location + LIVE_BASE_PATH: z.string().default("/live"), + // Compression options + COMPRESSION_LEVEL: z.string().default("6").transform(Number), + COMPRESSION_THRESHOLD: z.string().default("5000").transform(Number), + // secret + LIVE_SERVER_SECRET_KEY: z.string(), + // Redis configuration + REDIS_HOST: z.string().optional(), + REDIS_PORT: z.string().default("6379").transform(Number), + REDIS_URL: z.string().optional(), +}); + +const validateEnv = () => { + const result = envSchema.safeParse(process.env); + if (!result.success) { + console.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4)); + process.exit(1); + } + return result.data; +}; + +export const env = validateEnv(); diff --git a/plane-src/apps/live/src/extensions/database.ts b/plane-src/apps/live/src/extensions/database.ts new file mode 100644 index 0000000..becefc8 --- /dev/null +++ b/plane-src/apps/live/src/extensions/database.ts @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Database as HocuspocusDatabase } from "@hocuspocus/extension-database"; +// plane imports +import { + getAllDocumentFormatsFromDocumentEditorBinaryData, + getBinaryDataFromDocumentEditorHTMLString, +} from "@plane/editor"; +import type { TDocumentPayload } from "@plane/types"; +import { logger } from "@plane/logger"; +// lib +import { AppError } from "@/lib/errors"; +// services +import { getPageService } from "@/services/page/handler"; +// type +import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types"; +import { ForceCloseReason, CloseCode } from "@/types/admin-commands"; +import { broadcastError } from "@/utils/broadcast-error"; +// force close utility +import { forceCloseDocumentAcrossServers } from "./force-close-handler"; + +const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => { + try { + const service = getPageService(context.documentType, context); + // fetch details + const response = (await service.fetchDescriptionBinary(pageId)) as Buffer; + const binaryData = new Uint8Array(response); + // if binary data is empty, convert HTML to binary data + if (binaryData.byteLength === 0) { + const pageDetails = await service.fetchDetails(pageId); + const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString( + pageDetails.description_html ?? "

", + pageDetails.name + ); + if (convertedBinaryData) { + // save the converted binary data back to the database + try { + const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData( + convertedBinaryData, + true + ); + const payload: TDocumentPayload = { + description_binary: contentBinaryEncoded, + description_html: contentHTML, + description_json: contentJSON, + }; + await service.updateDescriptionBinary(pageId, payload); + } catch (e) { + const error = new AppError(e); + logger.error("Failed to save binary after first conversion from html:", error); + } + return convertedBinaryData; + } + } + // return binary data + return binaryData; + } catch (error) { + const appError = new AppError(error, { context: { pageId } }); + logger.error("Error in fetching document", appError); + + // Broadcast error to frontend for user document types + await broadcastError(instance, pageId, "Unable to load the page. Please try refreshing.", "fetch", context); + + throw appError; + } +}; + +const storeDocument = async ({ + context, + state: pageBinaryData, + documentName: pageId, + instance, +}: StorePayloadWithContext) => { + try { + const service = getPageService(context.documentType, context); + // convert binary data to all formats + const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData( + pageBinaryData, + true + ); + // create payload + const payload: TDocumentPayload = { + description_binary: contentBinaryEncoded, + description_html: contentHTML, + description_json: contentJSON, + }; + await service.updateDescriptionBinary(pageId, payload); + } catch (error) { + const appError = new AppError(error, { context: { pageId } }); + logger.error("Error in updating document:", appError); + + // Check error types + const isContentTooLarge = appError.statusCode === 413; + + // Determine if we should disconnect and unload + const shouldDisconnect = isContentTooLarge; + + // Determine error message and code + let errorMessage: string; + let errorCode: "content_too_large" | "page_locked" | "page_archived" | undefined; + + if (isContentTooLarge) { + errorMessage = "Document is too large to save. Please reduce the content size."; + errorCode = "content_too_large"; + } else { + errorMessage = "Unable to save the page. Please try again."; + } + + // Broadcast error to frontend for user document types + await broadcastError(instance, pageId, errorMessage, "store", context, errorCode, shouldDisconnect); + + // If we should disconnect, close connections and unload document + if (shouldDisconnect) { + // Map error code to ForceCloseReason with proper types + const reason = + errorCode === "content_too_large" ? ForceCloseReason.DOCUMENT_TOO_LARGE : ForceCloseReason.CRITICAL_ERROR; + + const closeCode = errorCode === "content_too_large" ? CloseCode.DOCUMENT_TOO_LARGE : CloseCode.FORCE_CLOSE; + + // force close connections and unload document + await forceCloseDocumentAcrossServers(instance, pageId, reason, closeCode); + + // Don't throw after force close - document is already unloaded + // Throwing would cause hocuspocus's finally block to access the null document + return; + } + + throw appError; + } +}; + +export class Database extends HocuspocusDatabase { + constructor() { + super({ fetch: fetchDocument, store: storeDocument }); + } +} diff --git a/plane-src/apps/live/src/extensions/force-close-handler.ts b/plane-src/apps/live/src/extensions/force-close-handler.ts new file mode 100644 index 0000000..b13e08e --- /dev/null +++ b/plane-src/apps/live/src/extensions/force-close-handler.ts @@ -0,0 +1,202 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Connection, Extension, Hocuspocus, onConfigurePayload } from "@hocuspocus/server"; +import { logger } from "@plane/logger"; +import { Redis } from "@/extensions/redis"; +import { AdminCommand, CloseCode, getForceCloseMessage, isForceCloseCommand } from "@/types/admin-commands"; +import type { ForceCloseReason, ClientForceCloseMessage, ForceCloseCommandData } from "@/types/admin-commands"; + +/** + * Extension to handle force close commands from other servers via Redis admin channel + */ +export class ForceCloseHandler implements Extension { + name = "ForceCloseHandler"; + priority = 999; + + async onConfigure({ instance }: onConfigurePayload) { + const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis); + + if (!redisExt) { + logger.warn("[FORCE_CLOSE_HANDLER] Redis extension not found"); + return; + } + + // Register handler for force_close admin command + redisExt.onAdminCommand(AdminCommand.FORCE_CLOSE, async (data) => { + // Type guard for safety + if (!isForceCloseCommand(data)) { + logger.error("[FORCE_CLOSE_HANDLER] Received invalid force close command"); + return; + } + + const { docId, reason, code } = data; + + const document = instance.documents.get(docId); + if (!document) { + // Not our document, ignore + return; + } + + const connectionCount = document.getConnectionsCount(); + logger.info(`[FORCE_CLOSE_HANDLER] Sending force close message to ${connectionCount} clients...`); + + // Step 1: Send force close message to ALL clients first + const forceCloseMessage: ClientForceCloseMessage = { + type: "force_close", + reason, + code, + message: getForceCloseMessage(reason), + timestamp: new Date().toISOString(), + }; + + let messageSent = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.sendStateless(JSON.stringify(forceCloseMessage)); + messageSent++; + } catch (error) { + logger.error("[FORCE_CLOSE_HANDLER] Failed to send message:", error); + } + }); + + logger.info(`[FORCE_CLOSE_HANDLER] Sent force close message to ${messageSent}/${connectionCount} clients`); + + // Wait a moment for messages to be delivered + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Step 2: Close connections + logger.info(`[FORCE_CLOSE_HANDLER] Closing ${connectionCount} connections...`); + + let closed = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.close({ code, reason }); + closed++; + } catch (error) { + logger.error("[FORCE_CLOSE_HANDLER] Failed to close connection:", error); + } + }); + + logger.info(`[FORCE_CLOSE_HANDLER] Closed ${closed}/${connectionCount} connections for ${docId}`); + }); + + logger.info("[FORCE_CLOSE_HANDLER] Registered with Redis extension"); + } +} + +/** + * Force close all connections to a document across all servers and unload it from memory. + * Used for critical errors or admin operations. + * + * @param instance - The Hocuspocus server instance + * @param pageId - The document ID to force close + * @param reason - The reason for force closing + * @param code - Optional WebSocket close code (defaults to FORCE_CLOSE) + * @returns Promise that resolves when document is closed and unloaded + * @throws Error if document not found in memory + */ +export const forceCloseDocumentAcrossServers = async ( + instance: Hocuspocus, + pageId: string, + reason: ForceCloseReason, + code: CloseCode = CloseCode.FORCE_CLOSE +): Promise => { + // STEP 1: VERIFY DOCUMENT EXISTS + const document = instance.documents.get(pageId); + + if (!document) { + logger.info(`[FORCE_CLOSE] Document ${pageId} already unloaded - no action needed`); + return; // Document already cleaned up, nothing to do + } + + const connectionsBefore = document.getConnectionsCount(); + logger.info(`[FORCE_CLOSE] Sending force close message to ${connectionsBefore} local clients...`); + + const forceCloseMessage: ClientForceCloseMessage = { + type: "force_close", + reason, + code, + message: getForceCloseMessage(reason), + timestamp: new Date().toISOString(), + }; + + let messageSentCount = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.sendStateless(JSON.stringify(forceCloseMessage)); + messageSentCount++; + } catch (error) { + logger.error("[FORCE_CLOSE] Failed to send message to client:", error); + } + }); + + logger.info(`[FORCE_CLOSE] Sent force close message to ${messageSentCount}/${connectionsBefore} clients`); + + // Wait a moment for messages to be delivered + await new Promise((resolve) => setTimeout(resolve, 50)); + + // STEP 3: CLOSE LOCAL CONNECTIONS + logger.info(`[FORCE_CLOSE] Closing ${connectionsBefore} local connections...`); + + let closedCount = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.close({ code, reason }); + closedCount++; + } catch (error) { + logger.error("[FORCE_CLOSE] Failed to close local connection:", error); + } + }); + + logger.info(`[FORCE_CLOSE] Closed ${closedCount}/${connectionsBefore} local connections`); + + // STEP 4: BROADCAST TO OTHER SERVERS + const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis); + + if (redisExt) { + const commandData: ForceCloseCommandData = { + command: AdminCommand.FORCE_CLOSE, + docId: pageId, + reason, + code, + originServer: instance.configuration.name || "unknown", + timestamp: new Date().toISOString(), + }; + + const receivers = await redisExt.publishAdminCommand(commandData); + logger.info(`[FORCE_CLOSE] Notified ${receivers} other server(s)`); + } else { + logger.warn("[FORCE_CLOSE] Redis extension not found, cannot notify other servers"); + } + + // STEP 5: WAIT FOR OTHER SERVERS + const waitTime = 800; + logger.info(`[FORCE_CLOSE] Waiting ${waitTime}ms for other servers to close connections...`); + await new Promise((resolve) => setTimeout(resolve, waitTime)); + + // STEP 6: UNLOAD DOCUMENT after closing all the connections + logger.info(`[FORCE_CLOSE] Unloading document from memory...`); + + try { + await instance.unloadDocument(document); + logger.info(`[FORCE_CLOSE] Document unloaded successfully ✅`); + } catch (unloadError: unknown) { + logger.error("[FORCE_CLOSE] UNLOAD FAILED:", unloadError); + logger.error(` Error: ${unloadError instanceof Error ? unloadError.message : "unknown"}`); + } + + // STEP 7: VERIFY UNLOAD + const documentAfterUnload = instance.documents.get(pageId); + + if (documentAfterUnload) { + logger.error( + `❌ [FORCE_CLOSE] Document still in memory!, Document ID: ${pageId}, Connections: ${documentAfterUnload.getConnectionsCount()}` + ); + } else { + logger.info(`✅ [FORCE_CLOSE] COMPLETE, Document: ${pageId}, Status: Successfully closed and unloaded`); + } +}; diff --git a/plane-src/apps/live/src/extensions/index.ts b/plane-src/apps/live/src/extensions/index.ts new file mode 100644 index 0000000..d55ca6e --- /dev/null +++ b/plane-src/apps/live/src/extensions/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Database } from "./database"; +import { ForceCloseHandler } from "./force-close-handler"; +import { Logger } from "./logger"; +import { Redis } from "./redis"; +import { TitleSyncExtension } from "./title-sync"; + +export const getExtensions = () => [ + new Logger(), + new Database(), + new Redis(), + new TitleSyncExtension(), + new ForceCloseHandler(), // Must be after Redis to receive broadcasts +]; diff --git a/plane-src/apps/live/src/extensions/logger.ts b/plane-src/apps/live/src/extensions/logger.ts new file mode 100644 index 0000000..f670b66 --- /dev/null +++ b/plane-src/apps/live/src/extensions/logger.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Logger as HocuspocusLogger } from "@hocuspocus/extension-logger"; +import { logger } from "@plane/logger"; + +export class Logger extends HocuspocusLogger { + constructor() { + super({ + onChange: false, + log: (message) => { + logger.info(message); + }, + }); + } +} diff --git a/plane-src/apps/live/src/extensions/redis.ts b/plane-src/apps/live/src/extensions/redis.ts new file mode 100644 index 0000000..900a001 --- /dev/null +++ b/plane-src/apps/live/src/extensions/redis.ts @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis"; +import { OutgoingMessage } from "@hocuspocus/server"; +import type { onConfigurePayload } from "@hocuspocus/server"; +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +import { redisManager } from "@/redis"; +import { AdminCommand } from "@/types/admin-commands"; +import type { AdminCommandData, AdminCommandHandler } from "@/types/admin-commands"; + +const getRedisClient = () => { + const redisClient = redisManager.getClient(); + if (!redisClient) { + throw new AppError("Redis client not initialized"); + } + return redisClient; +}; + +export class Redis extends HocuspocusRedis { + private adminHandlers = new Map(); + private readonly ADMIN_CHANNEL = "hocuspocus:admin"; + + constructor() { + super({ redis: getRedisClient() }); + } + + async onConfigure(payload: onConfigurePayload) { + await super.onConfigure(payload); + + // Subscribe to admin channel + await new Promise((resolve, reject) => { + this.sub.subscribe(this.ADMIN_CHANNEL, (error: Error) => { + if (error) { + logger.error(`[Redis] Failed to subscribe to admin channel:`, error); + reject(error); + } else { + logger.info(`[Redis] Subscribed to admin channel: ${this.ADMIN_CHANNEL}`); + resolve(); + } + }); + }); + + // Listen for admin messages + this.sub.on("message", this.handleAdminMessage); + logger.info(`[Redis] Attached admin message listener`); + } + + private handleAdminMessage = async (channel: string, message: string) => { + if (channel !== this.ADMIN_CHANNEL) return; + + try { + const data = JSON.parse(message) as AdminCommandData; + + // Validate command + if (!data.command || !Object.values(AdminCommand).includes(data.command as AdminCommand)) { + logger.warn(`[Redis] Invalid admin command received: ${data.command}`); + return; + } + + const handler = this.adminHandlers.get(data.command); + + if (handler) { + await handler(data); + } else { + logger.warn(`[Redis] No handler registered for admin command: ${data.command}`); + } + } catch (error) { + logger.error("[Redis] Error handling admin message:", error); + } + }; + + /** + * Register handler for an admin command + */ + public onAdminCommand( + command: AdminCommand, + handler: AdminCommandHandler + ) { + this.adminHandlers.set(command, handler as AdminCommandHandler); + logger.info(`[Redis] Registered admin command: ${command}`); + } + + /** + * Publish admin command to global channel + */ + public async publishAdminCommand(data: T): Promise { + // Validate command data + if (!data.command || !Object.values(AdminCommand).includes(data.command)) { + throw new AppError(`Invalid admin command: ${data.command}`); + } + + const message = JSON.stringify(data); + const receivers = await this.pub.publish(this.ADMIN_CHANNEL, message); + + logger.info(`[Redis] Published "${data.command}" command, received by ${receivers} server(s)`); + return receivers; + } + + async onDestroy() { + // Unsubscribe from admin channel + await new Promise((resolve) => { + this.sub.unsubscribe(this.ADMIN_CHANNEL, (error: Error) => { + if (error) { + logger.error(`[Redis] Error unsubscribing from admin channel:`, error); + } + resolve(); + }); + }); + + // Remove the message listener to prevent memory leaks + this.sub.removeListener("message", this.handleAdminMessage); + logger.info(`[Redis] Removed admin message listener`); + + await super.onDestroy(); + } + + /** + * Broadcast a message to a document across all servers via Redis. + * Uses empty identifier so ALL servers process the message. + */ + public async broadcastToDocument(documentName: string, payload: unknown): Promise { + const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload); + + const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload); + + const emptyPrefix = Buffer.concat([Buffer.from([0])]); + const channel = this["pubKey"](documentName); + const encodedMessage = Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())]); + + const result = await this.pub.publishBuffer(channel, encodedMessage); + + logger.info(`REDIS_EXTENSION: Published to ${documentName}, ${result} subscribers`); + + return result; + } +} diff --git a/plane-src/apps/live/src/extensions/title-sync.ts b/plane-src/apps/live/src/extensions/title-sync.ts new file mode 100644 index 0000000..c86b749 --- /dev/null +++ b/plane-src/apps/live/src/extensions/title-sync.ts @@ -0,0 +1,181 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// hocuspocus +import type { Extension, Hocuspocus, Document } from "@hocuspocus/server"; +import { TiptapTransformer } from "@hocuspocus/transformer"; +import type { AnyExtension, JSONContent } from "@tiptap/core"; +import type * as Y from "yjs"; +// editor extensions +import { + TITLE_EDITOR_EXTENSIONS, + createRealtimeEvent, + extractTextFromHTML, + generateTitleProsemirrorJson, +} from "@plane/editor"; +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +// helpers +import { getPageService } from "@/services/page/handler"; +import type { HocusPocusServerContext, OnLoadDocumentPayloadWithContext } from "@/types"; +import { broadcastMessageToPage } from "@/utils/broadcast-message"; +import { TitleUpdateManager } from "./title-update/title-update-manager"; + +/** + * Hocuspocus extension for synchronizing document titles + */ +export class TitleSyncExtension implements Extension { + // Maps document names to their observers and update managers + private titleObservers: Map[]) => void> = new Map(); + private titleUpdateManagers: Map = new Map(); + // Store minimal data needed for each document's title observer (prevents closure memory leaks) + private titleObserverData: Map< + string, + { + parentId?: string | null; + userId: string; + workspaceSlug: string | null; + instance: Hocuspocus; + } + > = new Map(); + + /** + * Handle document loading - migrate old titles if needed + */ + async onLoadDocument({ context, document, documentName }: OnLoadDocumentPayloadWithContext) { + try { + // initially for on demand migration of old titles to a new title field + // in the yjs binary + if (document.isEmpty("title")) { + const service = getPageService(context.documentType, context); + const pageDetails = await service.fetchDetails(documentName); + const title = pageDetails.name; + if (title == null) return; + const titleJson = (generateTitleProsemirrorJson as (text: string) => JSONContent)(title); + const titleField = TiptapTransformer.toYdoc(titleJson, "title", TITLE_EDITOR_EXTENSIONS as AnyExtension[]); + document.merge(titleField); + } + } catch (error) { + const appError = new AppError(error, { + context: { operation: "onLoadDocument", documentName }, + }); + logger.error("Error loading document title", appError); + } + } + /** + * Set up title synchronization for a document after it's loaded + */ + async afterLoadDocument({ + document, + documentName, + context, + instance, + }: { + document: Document; + documentName: string; + context: HocusPocusServerContext; + instance: Hocuspocus; + }) { + // Create a title update manager for this document + const updateManager = new TitleUpdateManager(documentName, context); + + // Store the manager + this.titleUpdateManagers.set(documentName, updateManager); + + // Store minimal data needed for the observer (prevents closure memory leak) + this.titleObserverData.set(documentName, { + userId: context.userId, + workspaceSlug: context.workspaceSlug, + instance: instance, + }); + + // Create observer using bound method to avoid closure capturing heavy objects + const titleObserver = this.handleTitleChange.bind(this, documentName); + + // Observe the title field + document.getXmlFragment("title").observeDeep(titleObserver); + this.titleObservers.set(documentName, titleObserver); + } + + /** + * Handle title changes for a document + * This is a separate method to avoid closure memory leaks + */ + private handleTitleChange(documentName: string, events: Y.YEvent[]) { + let title = ""; + events.forEach((event) => { + title = extractTextFromHTML(event.currentTarget.toJSON() as string); + }); + + // Get the manager for this document + const manager = this.titleUpdateManagers.get(documentName); + + // Get the stored data for this document + const data = this.titleObserverData.get(documentName); + + // Broadcast to parent page if it exists + if (data?.parentId && data.workspaceSlug && data.instance) { + const event = createRealtimeEvent({ + user_id: data.userId, + workspace_slug: data.workspaceSlug, + action: "property_updated", + page_id: documentName, + data: { name: title }, + descendants_ids: [], + }); + + // Use the instance from stored data (guaranteed to be set) + broadcastMessageToPage(data.instance, data.parentId, event); + } + + // Schedule the title update + if (manager) { + manager.scheduleUpdate(title); + } + } + + /** + * Force save title before unloading the document + */ + async beforeUnloadDocument({ documentName }: { documentName: string }) { + const updateManager = this.titleUpdateManagers.get(documentName); + if (updateManager) { + // Force immediate save and wait for it to complete + await updateManager.forceSave(); + // Clean up the manager + this.titleUpdateManagers.delete(documentName); + } + } + + /** + * Remove observers after document unload + */ + async afterUnloadDocument({ documentName, document }: { documentName: string; document?: Document }) { + // Clean up observer when document is unloaded + const observer = this.titleObservers.get(documentName); + if (observer) { + // unregister observer from Y.js document to prevent memory leak + if (document) { + try { + document.getXmlFragment("title").unobserveDeep(observer); + } catch (error) { + logger.error("Failed to unobserve title field", new AppError(error, { context: { documentName } })); + } + } + this.titleObservers.delete(documentName); + } + + // Clean up the observer data map to prevent memory leak + this.titleObserverData.delete(documentName); + + // Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run + if (this.titleUpdateManagers.has(documentName)) { + const manager = this.titleUpdateManagers.get(documentName)!; + manager.cancel(); + this.titleUpdateManagers.delete(documentName); + } + } +} diff --git a/plane-src/apps/live/src/extensions/title-update/debounce.ts b/plane-src/apps/live/src/extensions/title-update/debounce.ts new file mode 100644 index 0000000..9de1ba4 --- /dev/null +++ b/plane-src/apps/live/src/extensions/title-update/debounce.ts @@ -0,0 +1,283 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { logger } from "@plane/logger"; + +/** + * DebounceState - Tracks the state of a debounced function + */ +export interface DebounceState { + lastArgs: any[] | null; + timerId: ReturnType | null; + lastCallTime: number | undefined; + lastExecutionTime: number; + inProgress: boolean; + abortController: AbortController | null; +} + +/** + * Creates a new DebounceState object + */ +export const createDebounceState = (): DebounceState => ({ + lastArgs: null, + timerId: null, + lastCallTime: undefined, + lastExecutionTime: 0, + inProgress: false, + abortController: null, +}); + +/** + * DebounceOptions - Configuration options for debounce + */ +export interface DebounceOptions { + /** The wait time in milliseconds */ + wait: number; + + /** Optional logging prefix for debug messages */ + logPrefix?: string; +} + +/** + * Enhanced debounce manager with abort support + * Manages the state and timing of debounced function calls + */ +export class DebounceManager { + private state: DebounceState; + private wait: number; + private logPrefix: string; + + /** + * Creates a new DebounceManager + * @param options Debounce configuration options + */ + constructor(options: DebounceOptions) { + this.state = createDebounceState(); + this.wait = options.wait; + this.logPrefix = options.logPrefix || ""; + } + + /** + * Schedule a debounced function call + * @param func The function to call + * @param args The arguments to pass to the function + */ + schedule(func: (...args: any[]) => Promise, ...args: any[]): void { + // Always update the last arguments + this.state.lastArgs = args; + + const time = Date.now(); + this.state.lastCallTime = time; + + // If an operation is in progress, just store the new args and start the timer + if (this.state.inProgress) { + // Always restart the timer for the new call, even if an operation is in progress + if (this.state.timerId) { + clearTimeout(this.state.timerId); + } + + this.state.timerId = setTimeout(() => { + this.timerExpired(func); + }, this.wait); + return; + } + + // If already scheduled, update the args and restart the timer + if (this.state.timerId) { + clearTimeout(this.state.timerId); + this.state.timerId = setTimeout(() => { + this.timerExpired(func); + }, this.wait); + return; + } + + // Start the timer for the trailing edge execution + this.state.timerId = setTimeout(() => { + this.timerExpired(func); + }, this.wait); + } + + /** + * Called when the timer expires + */ + private timerExpired(func: (...args: any[]) => Promise): void { + const time = Date.now(); + + // Check if this timer expiration represents the end of the debounce period + if (this.shouldInvoke(time)) { + // Execute the function + this.executeFunction(func, time); + return; + } + + // Otherwise restart the timer + this.state.timerId = setTimeout(() => { + this.timerExpired(func); + }, this.remainingWait(time)); + } + + /** + * Execute the debounced function + */ + private executeFunction(func: (...args: any[]) => Promise, time: number): void { + this.state.timerId = null; + this.state.lastExecutionTime = time; + + // Execute the function asynchronously + this.performFunction(func).catch((error) => { + logger.error(`${this.logPrefix}: Error in execution:`, error); + }); + } + + /** + * Perform the actual function call, handling any in-progress operations + */ + private async performFunction(func: (...args: any[]) => Promise): Promise { + const args = this.state.lastArgs; + if (!args) return; + + // Store the args we're about to use + const currentArgs = [...args]; + + // If another operation is in progress, abort it + await this.abortOngoingOperation(); + + // Mark that we're starting a new operation + this.state.inProgress = true; + this.state.abortController = new AbortController(); + + try { + // Add the abort signal to the arguments if the function can use it + const execArgs = [...currentArgs]; + execArgs.push(this.state.abortController.signal); + + await func(...execArgs); + + // Only clear lastArgs if they haven't been changed during this operation + if (this.state.lastArgs && this.arraysEqual(this.state.lastArgs, currentArgs)) { + this.state.lastArgs = null; + + // Clear any timer as we've successfully processed the latest args + if (this.state.timerId) { + clearTimeout(this.state.timerId); + this.state.timerId = null; + } + } else if (this.state.lastArgs) { + // If lastArgs have changed during this operation, the timer should already be running + // but let's make sure it is + if (!this.state.timerId) { + this.state.timerId = setTimeout(() => { + this.timerExpired(func); + }, this.wait); + } + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + // Nothing to do here, the new operation will be triggered by the timer expiration + } else { + logger.error(`${this.logPrefix}: Error during operation:`, error); + + // On error (not abort), make sure we have a timer running to retry + if (!this.state.timerId && this.state.lastArgs) { + this.state.timerId = setTimeout(() => { + this.timerExpired(func); + }, this.wait); + } + } + } finally { + this.state.inProgress = false; + this.state.abortController = null; + } + } + + /** + * Abort any ongoing operation + */ + private async abortOngoingOperation(): Promise { + if (this.state.inProgress && this.state.abortController) { + this.state.abortController.abort(); + + // Small delay to ensure the abort has had time to propagate + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Double-check that state has been reset, force it if not + if (this.state.inProgress || this.state.abortController) { + this.state.inProgress = false; + this.state.abortController = null; + } + } + } + + /** + * Determine if we should invoke the function now + */ + private shouldInvoke(time: number): boolean { + // Either this is the first call, or we've waited long enough since the last call + return this.state.lastCallTime === undefined || time - this.state.lastCallTime >= this.wait; + } + + /** + * Calculate how much longer we should wait + */ + private remainingWait(time: number): number { + const timeSinceLastCall = time - (this.state.lastCallTime || 0); + return Math.max(0, this.wait - timeSinceLastCall); + } + + /** + * Force immediate execution + */ + async flush(func: (...args: any[]) => Promise): Promise { + // Clear any pending timeout + if (this.state.timerId) { + clearTimeout(this.state.timerId); + this.state.timerId = null; + } + + // Reset timing state + this.state.lastCallTime = undefined; + + // Perform the function immediately + if (this.state.lastArgs) { + await this.performFunction(func); + } + } + + /** + * Cancel any pending operations without executing + */ + cancel(): void { + // Clear any pending timeout + if (this.state.timerId) { + clearTimeout(this.state.timerId); + this.state.timerId = null; + } + + // Reset timing state + this.state.lastCallTime = undefined; + + // Abort any in-progress operation + if (this.state.inProgress && this.state.abortController) { + this.state.abortController.abort(); + this.state.inProgress = false; + this.state.abortController = null; + } + + // Clear args + this.state.lastArgs = null; + } + + /** + * Compare two arrays for equality + */ + private arraysEqual(a: any[], b: any[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; + } +} diff --git a/plane-src/apps/live/src/extensions/title-update/title-update-manager.ts b/plane-src/apps/live/src/extensions/title-update/title-update-manager.ts new file mode 100644 index 0000000..5521c10 --- /dev/null +++ b/plane-src/apps/live/src/extensions/title-update/title-update-manager.ts @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +import { getPageService } from "@/services/page/handler"; +import type { HocusPocusServerContext } from "@/types"; +import { DebounceManager } from "./debounce"; + +/** + * Manages title update operations for a single document + * Handles debouncing, aborting, and force saving title updates + */ +export class TitleUpdateManager { + private documentName: string; + private context: HocusPocusServerContext; + private debounceManager: DebounceManager; + private lastTitle: string | null = null; + + /** + * Create a new TitleUpdateManager instance + */ + constructor(documentName: string, context: HocusPocusServerContext, wait: number = 5000) { + this.documentName = documentName; + this.context = context; + + // Set up debounce manager with logging + this.debounceManager = new DebounceManager({ + wait, + logPrefix: `TitleManager[${documentName.substring(0, 8)}]`, + }); + } + + /** + * Schedule a debounced title update + */ + scheduleUpdate(title: string): void { + // Store the latest title + this.lastTitle = title; + + // Schedule the update with the debounce manager + this.debounceManager.schedule(this.updateTitle.bind(this), title); + } + + /** + * Update the title - will be called by the debounce manager + */ + private async updateTitle(title: string, signal?: AbortSignal): Promise { + const service = getPageService(this.context.documentType, this.context); + if (!service.updatePageProperties) { + logger.warn(`No updateTitle method found for document ${this.documentName}`); + return; + } + + try { + await service.updatePageProperties(this.documentName, { + data: { name: title }, + abortSignal: signal, + }); + + // Clear last title only if it matches what we just updated + if (this.lastTitle === title) { + this.lastTitle = null; + } + } catch (error) { + const appError = new AppError(error, { + context: { operation: "updateTitle", documentName: this.documentName }, + }); + logger.error("Error updating title", appError); + } + } + + /** + * Force save the current title immediately + */ + async forceSave(): Promise { + // Ensure we have the current title + if (!this.lastTitle) { + return; + } + + // Use the debounce manager to flush the operation + await this.debounceManager.flush(this.updateTitle.bind(this)); + } + + /** + * Cancel any pending updates + */ + cancel(): void { + this.debounceManager.cancel(); + this.lastTitle = null; + } +} diff --git a/plane-src/apps/live/src/extensions/title-update/title-utils.ts b/plane-src/apps/live/src/extensions/title-update/title-utils.ts new file mode 100644 index 0000000..ac23948 --- /dev/null +++ b/plane-src/apps/live/src/extensions/title-update/title-utils.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { sanitizeHTML } from "@plane/utils"; + +/** + * Utility function to extract text from HTML content + */ +export const extractTextFromHTML = (html: string): string => { + // Use sanitizeHTML to safely extract text and remove all HTML tags + // This is more secure than regex as it handles edge cases and prevents injection + // Note: sanitizeHTML trims whitespace, which is acceptable for title extraction + return sanitizeHTML(html) || ""; +}; diff --git a/plane-src/apps/live/src/hocuspocus.ts b/plane-src/apps/live/src/hocuspocus.ts new file mode 100644 index 0000000..93ebf72 --- /dev/null +++ b/plane-src/apps/live/src/hocuspocus.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Hocuspocus } from "@hocuspocus/server"; +import { v4 as uuidv4 } from "uuid"; +// env +import { env } from "@/env"; +// extensions +import { getExtensions } from "@/extensions"; +// lib +import { onAuthenticate } from "@/lib/auth"; +import { onStateless } from "@/lib/stateless"; + +export class HocusPocusServerManager { + private static instance: HocusPocusServerManager | null = null; + private server: Hocuspocus | null = null; + // server options + private serverName = env.HOSTNAME || uuidv4(); + + private constructor() { + // Private constructor to prevent direct instantiation + } + + /** + * Get the singleton instance of HocusPocusServerManager + */ + public static getInstance(): HocusPocusServerManager { + if (!HocusPocusServerManager.instance) { + HocusPocusServerManager.instance = new HocusPocusServerManager(); + } + return HocusPocusServerManager.instance; + } + + /** + * Initialize and configure the HocusPocus server + */ + public async initialize(): Promise { + if (this.server) { + return this.server; + } + + this.server = new Hocuspocus({ + name: this.serverName, + onAuthenticate, + onStateless, + extensions: getExtensions(), + debounce: 10000, + }); + + return this.server; + } + + /** + * Get the configured server instance + */ + public getServer(): Hocuspocus | null { + return this.server; + } + + /** + * Reset the singleton instance (useful for testing) + */ + public static resetInstance(): void { + HocusPocusServerManager.instance = null; + } +} diff --git a/plane-src/apps/live/src/lib/auth-middleware.ts b/plane-src/apps/live/src/lib/auth-middleware.ts new file mode 100644 index 0000000..fcf06f8 --- /dev/null +++ b/plane-src/apps/live/src/lib/auth-middleware.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Request, Response, NextFunction } from "express"; +import { logger } from "@plane/logger"; +import { env } from "@/env"; + +/** + * Express middleware to verify secret key authentication for protected endpoints + * + * Checks for secret key in headers: + * - x-admin-secret-key (preferred for admin endpoints) + * - live-server-secret-key (for backward compatibility) + * + * @param req - Express request object + * @param res - Express response object + * @param next - Express next function + * + * @example + * ```typescript + * import { Middleware } from "@plane/decorators"; + * import { requireSecretKey } from "@/lib/auth-middleware"; + * + * @Get("/protected") + * @Middleware(requireSecretKey) + * async protectedEndpoint(req: Request, res: Response) { + * // This will only execute if secret key is valid + * } + * ``` + */ +// TODO - Move to hmac +export const requireSecretKey = (req: Request, res: Response, next: NextFunction): void => { + const secretKey = req.headers["live-server-secret-key"]; + + if (!secretKey || secretKey !== env.LIVE_SERVER_SECRET_KEY) { + logger.warn(` + ⚠️ [AUTH] Unauthorized access attempt + Endpoint: ${req.path} + Method: ${req.method} + IP: ${req.ip} + User-Agent: ${req.headers["user-agent"]} + `); + + res.status(401).json({ + error: "Unauthorized", + status: 401, + }); + return; + } + + // Secret key is valid, proceed to the route handler + next(); +}; diff --git a/plane-src/apps/live/src/lib/auth.ts b/plane-src/apps/live/src/lib/auth.ts new file mode 100644 index 0000000..02aa69c --- /dev/null +++ b/plane-src/apps/live/src/lib/auth.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// plane imports +import type { IncomingHttpHeaders } from "http"; +import type { TUserDetails } from "@plane/editor"; +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +// services +import { UserService } from "@/services/user.service"; +// types +import type { HocusPocusServerContext, TDocumentTypes } from "@/types"; + +/** + * Authenticate the user + * @param requestHeaders - The request headers + * @param context - The context + * @param token - The token + * @returns The authenticated user + */ +export const onAuthenticate = async ({ + requestHeaders, + requestParameters, + context, + token, +}: { + requestHeaders: IncomingHttpHeaders; + context: HocusPocusServerContext; + requestParameters: URLSearchParams; + token: string; +}) => { + let cookie: string | undefined = undefined; + let userId: string | undefined = undefined; + + // Extract cookie (fallback to request headers) and userId from token (for scenarios where + // the cookies are not passed in the request headers) + try { + const parsedToken = JSON.parse(token) as TUserDetails; + userId = parsedToken.id; + cookie = parsedToken.cookie; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "onAuthenticate" }, + }); + logger.error("Token parsing failed, using request headers", appError); + } finally { + // If cookie is still not found, fallback to request headers + if (!cookie) { + cookie = requestHeaders.cookie?.toString(); + } + } + + if (!cookie || !userId) { + const appError = new AppError("Credentials not provided", { code: "AUTH_MISSING_CREDENTIALS" }); + logger.error("Credentials not provided", appError); + throw appError; + } + + // set cookie in context, so it can be used throughout the ws connection + context.cookie = cookie ?? requestParameters.get("cookie") ?? ""; + context.documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes; + context.projectId = requestParameters.get("projectId"); + context.userId = userId; + context.workspaceSlug = requestParameters.get("workspaceSlug"); + + return await handleAuthentication({ + cookie: context.cookie, + userId: context.userId, + }); +}; + +export const handleAuthentication = async ({ cookie, userId }: { cookie: string; userId: string }) => { + // fetch current user info + try { + const userService = new UserService(); + const user = await userService.currentUser(cookie); + if (user.id !== userId) { + throw new AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" }); + } + + return { + user: { + id: user.id, + name: user.display_name, + }, + }; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "handleAuthentication" }, + }); + logger.error("Authentication failed", appError); + throw new AppError("Authentication unsuccessful", { code: appError.code }); + } +}; diff --git a/plane-src/apps/live/src/lib/errors.ts b/plane-src/apps/live/src/lib/errors.ts new file mode 100644 index 0000000..4e2bc26 --- /dev/null +++ b/plane-src/apps/live/src/lib/errors.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { AxiosError } from "axios"; + +/** + * Application error class that sanitizes and standardizes errors across the app. + * Extracts only essential information from AxiosError to prevent massive log bloat + * and sensitive data leaks (cookies, tokens, etc). + * + * Usage: + * new AppError("Simple error message") + * new AppError("Custom error", { code: "MY_CODE", statusCode: 400 }) + * new AppError(axiosError) // Auto-extracts essential info + * new AppError(anyError) // Works with any error type + */ +export class AppError extends Error { + statusCode?: number; + method?: string; + url?: string; + code?: string; + context?: Record; + + constructor(messageOrError: string | unknown, data?: Partial>) { + // Handle error objects - extract essential info + const error = messageOrError; + + // Already AppError - return immediately for performance (no need to re-process) + if (error instanceof AppError) { + return error; + } + + // Handle string message (simple case like regular Error) + if (typeof messageOrError === "string") { + super(messageOrError); + this.name = "AppError"; + if (data) { + Object.assign(this, data); + } + return; + } + + // AxiosError - extract ONLY essential info (no config, no headers, no cookies) + if (error && typeof error === "object" && "isAxiosError" in error) { + const axiosError = error as AxiosError; + const responseData = axiosError.response?.data as any; + super(responseData?.message || axiosError.message); + this.name = "AppError"; + this.statusCode = axiosError.response?.status; + this.method = axiosError.config?.method?.toUpperCase(); + this.url = axiosError.config?.url; + this.code = axiosError.code; + return; + } + + // DOMException (AbortError from cancelled requests) + if (error instanceof DOMException && error.name === "AbortError") { + super(error.message); + this.name = "AppError"; + this.code = "ABORT_ERROR"; + return; + } + + // Standard Error objects + if (error instanceof Error) { + super(error.message); + this.name = "AppError"; + this.code = error.name; + return; + } + + // Unknown error types - safe fallback + super("Unknown error occurred"); + this.name = "AppError"; + } +} diff --git a/plane-src/apps/live/src/lib/pdf/colors.ts b/plane-src/apps/live/src/lib/pdf/colors.ts new file mode 100644 index 0000000..1b220d7 --- /dev/null +++ b/plane-src/apps/live/src/lib/pdf/colors.ts @@ -0,0 +1,231 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +/** + * PDF Export Color Constants + * + * These colors are mapped from the editor CSS variables and tailwind-config tokens + * to ensure PDF exports match the editor's appearance. + * + * Source mappings: + * - Editor colors: packages/editor/src/styles/variables.css + * - Tailwind tokens: packages/tailwind-config/variables.css + */ + +// Editor text colors (from variables.css :root) +export const EDITOR_TEXT_COLORS = { + gray: "#5c5e63", + peach: "#ff5b59", + pink: "#f65385", + orange: "#fd9038", + green: "#0fc27b", + "light-blue": "#17bee9", + "dark-blue": "#266df0", + purple: "#9162f9", +} as const; + +// Editor background colors - Light theme (from variables.css [data-theme*="light"]) +export const EDITOR_BACKGROUND_COLORS_LIGHT = { + gray: "#d6d6d8", + peach: "#ffd5d7", + pink: "#fdd4e3", + orange: "#ffe3cd", + green: "#c3f0de", + "light-blue": "#c5eff9", + "dark-blue": "#c9dafb", + purple: "#e3d8fd", +} as const; + +// Editor background colors - Dark theme (from variables.css [data-theme*="dark"]) +export const EDITOR_BACKGROUND_COLORS_DARK = { + gray: "#404144", + peach: "#593032", + pink: "#562e3d", + orange: "#583e2a", + green: "#1d4a3b", + "light-blue": "#1f495c", + "dark-blue": "#223558", + purple: "#3d325a", +} as const; + +// Use light theme colors by default for PDF exports +export const EDITOR_BACKGROUND_COLORS = EDITOR_BACKGROUND_COLORS_LIGHT; + +// Color key type +export type EditorColorKey = keyof typeof EDITOR_TEXT_COLORS; + +/** + * Maps a color key to its text color hex value + */ +export const getTextColorHex = (colorKey: string): string | null => { + if (colorKey in EDITOR_TEXT_COLORS) { + return EDITOR_TEXT_COLORS[colorKey as EditorColorKey]; + } + return null; +}; + +/** + * Maps a color key to its background color hex value + */ +export const getBackgroundColorHex = (colorKey: string): string | null => { + if (colorKey in EDITOR_BACKGROUND_COLORS) { + return EDITOR_BACKGROUND_COLORS[colorKey as EditorColorKey]; + } + return null; +}; + +/** + * Checks if a value is a CSS variable reference (e.g., "var(--editor-colors-gray-text)") + */ +export const isCssVariable = (value: string): boolean => { + return value.startsWith("var("); +}; + +/** + * Extracts the color key from a CSS variable reference + * e.g., "var(--editor-colors-gray-text)" -> "gray" + * e.g., "var(--editor-colors-light-blue-background)" -> "light-blue" + */ +export const extractColorKeyFromCssVariable = (cssVar: string): string | null => { + // Match patterns like: var(--editor-colors-{color}-text) or var(--editor-colors-{color}-background) + const match = cssVar.match(/var\(--editor-colors-([\w-]+)-(text|background)\)/); + if (match) { + return match[1]; + } + return null; +}; + +/** + * Resolves a color value to a hex color for PDF rendering + * Handles both direct hex values and CSS variable references + */ +export const resolveColorForPdf = (value: string | null | undefined, type: "text" | "background"): string | null => { + if (!value) return null; + + // If it's already a hex color, return it + if (value.startsWith("#")) { + return value; + } + + // If it's a CSS variable, extract the key and get the hex value + if (isCssVariable(value)) { + const colorKey = extractColorKeyFromCssVariable(value); + if (colorKey) { + return type === "text" ? getTextColorHex(colorKey) : getBackgroundColorHex(colorKey); + } + } + + // If it's just a color key (e.g., "gray", "peach"), get the hex value + if (type === "text") { + return getTextColorHex(value); + } + return getBackgroundColorHex(value); +}; + +// Semantic colors from tailwind-config (light theme) +// These are derived from the CSS variables in packages/tailwind-config/variables.css + +// Neutral colors (light theme) +export const NEUTRAL_COLORS = { + white: "#ffffff", + 100: "#fafafa", // oklch(0.9848 0.0003 230.66) ≈ #fafafa + 200: "#f5f5f5", // oklch(0.9696 0.0007 230.67) ≈ #f5f5f5 + 300: "#f0f0f0", // oklch(0.9543 0.001 230.67) ≈ #f0f0f0 + 400: "#ebebeb", // oklch(0.9389 0.0014 230.68) ≈ #ebebeb + 500: "#e5e5e5", // oklch(0.9235 0.001733 230.6853) ≈ #e5e5e5 + 600: "#d9d9d9", // oklch(0.8925 0.0024 230.7) ≈ #d9d9d9 + 700: "#cccccc", // oklch(0.8612 0.0032 230.71) ≈ #cccccc + 800: "#8c8c8c", // oklch(0.6668 0.0079 230.82) ≈ #8c8c8c + 900: "#7a7a7a", // oklch(0.6161 0.009153 230.867) ≈ #7a7a7a + 1000: "#636363", // oklch(0.5288 0.0083 230.88) ≈ #636363 + 1100: "#4d4d4d", // oklch(0.4377 0.0066 230.87) ≈ #4d4d4d + 1200: "#1f1f1f", // oklch(0.2378 0.0029 230.83) ≈ #1f1f1f + black: "#0f0f0f", // oklch(0.1472 0.0034 230.83) ≈ #0f0f0f +} as const; + +// Brand colors (light theme accent) +export const BRAND_COLORS = { + default: "#3f76ff", // oklch(0.4799 0.1158 242.91) - primary accent blue + 100: "#f5f8ff", + 200: "#e8f0ff", + 300: "#d1e1ff", + 400: "#b3d0ff", + 500: "#8ab8ff", + 600: "#5c9aff", + 700: "#3f76ff", + 900: "#2952b3", + 1000: "#1e3d80", + 1100: "#142b5c", + 1200: "#0d1f40", +} as const; + +// Semantic text colors +export const TEXT_COLORS = { + primary: NEUTRAL_COLORS[1200], // --txt-primary + secondary: NEUTRAL_COLORS[1100], // --txt-secondary + tertiary: NEUTRAL_COLORS[1000], // --txt-tertiary + placeholder: NEUTRAL_COLORS[900], // --txt-placeholder + disabled: NEUTRAL_COLORS[800], // --txt-disabled + accentPrimary: BRAND_COLORS.default, // --txt-accent-primary + linkPrimary: BRAND_COLORS.default, // --txt-link-primary +} as const; + +// Semantic background colors +export const BACKGROUND_COLORS = { + canvas: NEUTRAL_COLORS[300], // --bg-canvas + surface1: NEUTRAL_COLORS.white, // --bg-surface-1 + surface2: NEUTRAL_COLORS[100], // --bg-surface-2 + layer1: NEUTRAL_COLORS[200], // --bg-layer-1 + layer2: NEUTRAL_COLORS.white, // --bg-layer-2 + layer3: NEUTRAL_COLORS[300], // --bg-layer-3 + accentSubtle: "#f5f8ff", // --bg-accent-subtle (brand-100) +} as const; + +// Semantic border colors +export const BORDER_COLORS = { + subtle: NEUTRAL_COLORS[400], // --border-subtle + subtle1: NEUTRAL_COLORS[500], // --border-subtle-1 + strong: NEUTRAL_COLORS[600], // --border-strong + strong1: NEUTRAL_COLORS[700], // --border-strong-1 + accentStrong: BRAND_COLORS.default, // --border-accent-strong +} as const; + +// Code/inline code colors +export const CODE_COLORS = { + background: NEUTRAL_COLORS[200], // Similar to bg-layer-1 + text: "#dc2626", // Red for inline code text (matches editor) + blockText: NEUTRAL_COLORS[1200], // Regular text for code blocks +} as const; + +// Link colors +export const LINK_COLORS = { + primary: BRAND_COLORS.default, + hover: BRAND_COLORS[900], +} as const; + +// Mention colors (from pi-chat-editor mention styles: bg-accent-primary/20 text-accent-primary) +export const MENTION_COLORS = { + background: "#e0e9ff", // accent-primary with ~20% opacity on white + text: BRAND_COLORS.default, +} as const; + +// Success/Green colors +export const SUCCESS_COLORS = { + primary: "#10b981", + subtle: "#d1fae5", +} as const; + +// Warning/Amber colors +export const WARNING_COLORS = { + primary: "#f59e0b", + subtle: "#fef3c7", +} as const; + +// Danger/Red colors +export const DANGER_COLORS = { + primary: "#ef4444", + subtle: "#fee2e2", +} as const; diff --git a/plane-src/apps/live/src/lib/pdf/icons.tsx b/plane-src/apps/live/src/lib/pdf/icons.tsx new file mode 100644 index 0000000..92621f3 --- /dev/null +++ b/plane-src/apps/live/src/lib/pdf/icons.tsx @@ -0,0 +1,232 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Circle, Path, Rect, Svg } from "@react-pdf/renderer"; + +type IconProps = { + size?: number; + color?: string; +}; + +// Lightbulb icon for callouts (default) +export const LightbulbIcon = ({ size = 16, color = "#ffffff" }: IconProps) => ( + + + +); + +// Document/file icon for page embeds +export const DocumentIcon = ({ size = 12, color = "#1e40af" }: IconProps) => ( + + + + + +); + +// Link icon for page links and external links +export const LinkIcon = ({ size = 12, color = "#2563eb" }: IconProps) => ( + + + + +); + +// Paperclip icon for attachments (default) +export const PaperclipIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + +); + +// Image icon for image attachments +export const ImageIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + + +); + +// Video icon for video attachments +export const VideoIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Music/audio icon +export const MusicIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + + +); + +// File-text icon for PDFs and documents +export const FileTextIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Table/spreadsheet icon +export const TableIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Presentation icon +export const PresentationIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Archive/zip icon +export const ArchiveIcon = ({ size = 16, color = "#374151" }: IconProps) => ( + + + + +); + +// Globe icon for external embeds (rich cards) +export const GlobeIcon = ({ size = 12, color = "#374151" }: IconProps) => ( + + + + +); + +// Clipboard icon for whiteboards +export const ClipboardIcon = ({ size = 12, color = "#6b7280" }: IconProps) => ( + + + + +); + +// Ruler/diagram icon for diagrams +export const DiagramIcon = ({ size = 12, color = "#6b7280" }: IconProps) => ( + + + + + +); + +// Work item / task icon +export const TaskIcon = ({ size = 14, color = "#374151" }: IconProps) => ( + + + + +); + +// Checkmark icon for checked task items +export const CheckIcon = ({ size = 10, color = "#ffffff" }: IconProps) => ( + + + +); + +// Helper to get file icon component based on file type +export const getFileIcon = (fileType: string, size = 16, color = "#374151") => { + if (fileType.startsWith("image/")) return ; + if (fileType.startsWith("video/")) return ; + if (fileType.startsWith("audio/")) return ; + if (fileType.includes("pdf")) return ; + if (fileType.includes("spreadsheet") || fileType.includes("excel")) return ; + if (fileType.includes("document") || fileType.includes("word")) return ; + if (fileType.includes("presentation") || fileType.includes("powerpoint")) + return ; + if (fileType.includes("zip") || fileType.includes("archive")) return ; + return ; +}; diff --git a/plane-src/apps/live/src/lib/pdf/index.ts b/plane-src/apps/live/src/lib/pdf/index.ts new file mode 100644 index 0000000..f3fe478 --- /dev/null +++ b/plane-src/apps/live/src/lib/pdf/index.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export { createPdfDocument, renderPlaneDocToPdfBlob, renderPlaneDocToPdfBuffer } from "./plane-pdf-exporter"; +export { createKeyGenerator, nodeRenderers, renderNode } from "./node-renderers"; +export { markRenderers, applyMarks } from "./mark-renderers"; +export { pdfStyles } from "./styles"; +export type { + KeyGenerator, + MarkRendererRegistry, + NodeRendererRegistry, + PDFExportMetadata, + PDFExportOptions, + PDFMarkRenderer, + PDFNodeRenderer, + PDFRenderContext, + PDFUserMention, + TipTapDocument, + TipTapMark, + TipTapNode, +} from "./types"; diff --git a/plane-src/apps/live/src/lib/pdf/mark-renderers.ts b/plane-src/apps/live/src/lib/pdf/mark-renderers.ts new file mode 100644 index 0000000..1f40c4e --- /dev/null +++ b/plane-src/apps/live/src/lib/pdf/mark-renderers.ts @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Style } from "@react-pdf/types"; +import { + BACKGROUND_COLORS, + CODE_COLORS, + EDITOR_BACKGROUND_COLORS, + EDITOR_TEXT_COLORS, + LINK_COLORS, + resolveColorForPdf, +} from "./colors"; +import type { MarkRendererRegistry, TipTapMark } from "./types"; + +export const markRenderers: MarkRendererRegistry = { + bold: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + fontWeight: "bold", + }), + + italic: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + fontStyle: "italic", + }), + + underline: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + textDecoration: "underline", + }), + + strike: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + textDecoration: "line-through", + }), + + code: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + fontFamily: "Courier", + fontSize: 10, + backgroundColor: BACKGROUND_COLORS.layer1, + color: CODE_COLORS.text, + }), + + link: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + color: LINK_COLORS.primary, + textDecoration: "underline", + }), + + textStyle: (mark: TipTapMark, style: Style): Style => { + const attrs = mark.attrs || {}; + const newStyle: Style = { ...style }; + + if (attrs.color && typeof attrs.color === "string") { + newStyle.color = attrs.color; + } + + if (attrs.backgroundColor && typeof attrs.backgroundColor === "string") { + newStyle.backgroundColor = attrs.backgroundColor; + } + + return newStyle; + }, + + highlight: (mark: TipTapMark, style: Style): Style => { + const attrs = mark.attrs || {}; + return { + ...style, + backgroundColor: (attrs.color as string) || EDITOR_BACKGROUND_COLORS.purple, + }; + }, + + subscript: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + fontSize: 8, + }), + + superscript: (_mark: TipTapMark, style: Style): Style => ({ + ...style, + fontSize: 8, + }), + + /** + * Custom color mark handler + * Handles the customColor extension which stores colors as data-text-color and data-background-color attributes + * The colors can be either: + * 1. Color keys like "gray", "peach", "pink", etc. (from COLORS_LIST) + * 2. Direct hex values for custom colors + * 3. CSS variable references like "var(--editor-colors-gray-text)" + */ + customColor: (mark: TipTapMark, style: Style): Style => { + const attrs = mark.attrs || {}; + const newStyle: Style = { ...style }; + + // Handle text color (stored in 'color' attribute) + const textColor = attrs.color as string | undefined; + if (textColor) { + const resolvedColor = resolveColorForPdf(textColor, "text"); + if (resolvedColor) { + newStyle.color = resolvedColor; + } else if (textColor.startsWith("#") || textColor.startsWith("rgb")) { + // Direct color value + newStyle.color = textColor; + } else if (textColor in EDITOR_TEXT_COLORS) { + // Color key lookup + newStyle.color = EDITOR_TEXT_COLORS[textColor as keyof typeof EDITOR_TEXT_COLORS]; + } + } + + // Handle background color (stored in 'backgroundColor' attribute) + const backgroundColor = attrs.backgroundColor as string | undefined; + if (backgroundColor) { + const resolvedColor = resolveColorForPdf(backgroundColor, "background"); + if (resolvedColor) { + newStyle.backgroundColor = resolvedColor; + } else if (backgroundColor.startsWith("#") || backgroundColor.startsWith("rgb")) { + // Direct color value + newStyle.backgroundColor = backgroundColor; + } else if (backgroundColor in EDITOR_BACKGROUND_COLORS) { + // Color key lookup + newStyle.backgroundColor = EDITOR_BACKGROUND_COLORS[backgroundColor as keyof typeof EDITOR_BACKGROUND_COLORS]; + } + } + + return newStyle; + }, +}; + +export const applyMarks = (marks: TipTapMark[] | undefined, baseStyle: Style = {}): Style => { + if (!marks || marks.length === 0) { + return baseStyle; + } + + return marks.reduce((style, mark) => { + const renderer = markRenderers[mark.type]; + if (renderer) { + return renderer(mark, style); + } + return style; + }, baseStyle); +}; diff --git a/plane-src/apps/live/src/lib/pdf/node-renderers.tsx b/plane-src/apps/live/src/lib/pdf/node-renderers.tsx new file mode 100644 index 0000000..003d21f --- /dev/null +++ b/plane-src/apps/live/src/lib/pdf/node-renderers.tsx @@ -0,0 +1,444 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Image, Link, Text, View } from "@react-pdf/renderer"; +import type { Style } from "@react-pdf/types"; +import type { ReactElement } from "react"; +import { CORE_EXTENSIONS } from "@plane/editor"; +import { BACKGROUND_COLORS, EDITOR_BACKGROUND_COLORS, resolveColorForPdf, TEXT_COLORS } from "./colors"; +import { CheckIcon, ClipboardIcon, DocumentIcon, GlobeIcon, LightbulbIcon, LinkIcon } from "./icons"; +import { applyMarks } from "./mark-renderers"; +import { pdfStyles } from "./styles"; +import type { KeyGenerator, NodeRendererRegistry, PDFExportMetadata, PDFRenderContext, TipTapNode } from "./types"; + +const getCalloutIcon = (node: TipTapNode, color: string): ReactElement => { + const logoInUse = node.attrs?.["data-logo-in-use"] as string | undefined; + const iconName = node.attrs?.["data-icon-name"] as string | undefined; + const iconColor = (node.attrs?.["data-icon-color"] as string) || color; + + if (logoInUse === "emoji") { + const emojiUnicode = node.attrs?.["data-emoji-unicode"] as string | undefined; + if (emojiUnicode) { + return {emojiUnicode}; + } + } + + if (iconName) { + switch (iconName) { + case "FileText": + case "File": + return ; + case "Link": + return ; + case "Globe": + return ; + case "Clipboard": + return ; + case "CheckSquare": + case "Check": + return ; + case "Lightbulb": + default: + return ; + } + } + + return ; +}; + +export const createKeyGenerator = (): KeyGenerator => { + let counter = 0; + return () => `node-${counter++}`; +}; + +const renderTextWithMarks = (node: TipTapNode, getKey: KeyGenerator): ReactElement => { + const style = applyMarks(node.marks, {}); + const hasLink = node.marks?.find((m) => m.type === "link"); + + if (hasLink) { + const href = (hasLink.attrs?.href as string) || "#"; + return ( + + {node.text || ""} + + ); + } + + return ( + + {node.text || ""} + + ); +}; + +const getTextAlignStyle = (textAlign: string | null | undefined): Style => { + if (!textAlign) return {}; + return { + textAlign: textAlign as "left" | "right" | "center" | "justify", + }; +}; + +const getFlexAlignStyle = (textAlign: string | null | undefined): Style => { + if (!textAlign) return {}; + if (textAlign === "right") return { alignItems: "flex-end" }; + if (textAlign === "center") return { alignItems: "center" }; + return {}; +}; + +export const nodeRenderers: NodeRendererRegistry = { + doc: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + {children} + ), + + text: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => + renderTextWithMarks(node, ctx.getKey), + + paragraph: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const textAlign = node.attrs?.textAlign as string | null; + const background = node.attrs?.backgroundColor as string | undefined; + const alignStyle = getTextAlignStyle(textAlign); + const flexStyle = getFlexAlignStyle(textAlign); + const resolvedBgColor = + background && background !== "default" ? resolveColorForPdf(background, "background") : null; + const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {}; + + return ( + + {children} + + ); + }, + + heading: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const level = (node.attrs?.level as number) || 1; + const styleKey = `heading${level}` as keyof typeof pdfStyles; + const style = pdfStyles[styleKey] || pdfStyles.heading1; + const textAlign = node.attrs?.textAlign as string | null; + const alignStyle = getTextAlignStyle(textAlign); + const flexStyle = getFlexAlignStyle(textAlign); + + return ( + + {children} + + ); + }, + + blockquote: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + + {children} + + ), + + codeBlock: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const codeContent = node.content?.map((c) => c.text || "").join("") || ""; + return ( + + {codeContent} + + ); + }, + + bulletList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const nestingLevel = (node.attrs?._nestingLevel as number) || 0; + const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {}; + return ( + + {children} + + ); + }, + + orderedList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const nestingLevel = (node.attrs?._nestingLevel as number) || 0; + const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {}; + return ( + + {children} + + ); + }, + + listItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const isOrdered = node.attrs?._parentType === "orderedList"; + const index = (node.attrs?._listItemIndex as number) || 0; + + const bullet = isOrdered ? `${index}.` : "•"; + + const textAlign = node.attrs?._textAlign as string | null; + const flexStyle = getFlexAlignStyle(textAlign); + + return ( + + + {bullet} + + {children} + + ); + }, + + taskList: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + + {children} + + ), + + taskItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const checked = node.attrs?.checked === true; + return ( + + + {checked && } + + {children} + + ); + }, + + table: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + + {children} + + ), + + tableRow: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const isHeader = node.attrs?._isHeader === true; + return ( + + {children} + + ); + }, + + tableHeader: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const colwidth = node.attrs?.colwidth as number[] | undefined; + const background = node.attrs?.background as string | undefined; + const width = colwidth?.[0]; + const widthStyle = width ? { width, flex: undefined } : {}; + const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null; + const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {}; + + return ( + + {children} + + ); + }, + + tableCell: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const colwidth = node.attrs?.colwidth as number[] | undefined; + const background = node.attrs?.background as string | undefined; + const width = colwidth?.[0]; + const widthStyle = width ? { width, flex: undefined } : {}; + const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null; + const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {}; + + return ( + + {children} + + ); + }, + + horizontalRule: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + + ), + + hardBreak: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => ( + {"\n"} + ), + + image: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + if (ctx.metadata?.noAssets) { + return ; + } + + const src = (node.attrs?.src as string) || ""; + const width = node.attrs?.width as number | undefined; + const alignment = (node.attrs?.alignment as string) || "left"; + + if (!src) { + return ; + } + + const alignmentStyle = + alignment === "center" + ? { alignItems: "center" as const } + : alignment === "right" + ? { alignItems: "flex-end" as const } + : { alignItems: "flex-start" as const }; + + return ( + + + + ); + }, + + imageComponent: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + if (ctx.metadata?.noAssets) { + return ; + } + + const assetId = (node.attrs?.src as string) || ""; + const rawWidth = node.attrs?.width; + const width = typeof rawWidth === "string" ? parseInt(rawWidth, 10) : (rawWidth as number | undefined); + const alignment = (node.attrs?.alignment as string) || "left"; + + if (!assetId) { + return ; + } + + let resolvedSrc = assetId; + if (ctx.metadata?.resolvedImageUrls && ctx.metadata.resolvedImageUrls[assetId]) { + resolvedSrc = ctx.metadata.resolvedImageUrls[assetId]; + } + + const alignmentStyle = + alignment === "center" + ? { alignItems: "center" as const } + : alignment === "right" + ? { alignItems: "flex-end" as const } + : { alignItems: "flex-start" as const }; + + if (!resolvedSrc.startsWith("http") && !resolvedSrc.startsWith("data:")) { + return ( + + [Image: {assetId.slice(0, 8)}...] + + ); + } + + const imageStyle = width && !isNaN(width) ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 }; + + return ( + + + + ); + }, + + calloutComponent: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const backgroundKey = (node.attrs?.["data-background"] as string) || "gray"; + const backgroundColor = + EDITOR_BACKGROUND_COLORS[backgroundKey as keyof typeof EDITOR_BACKGROUND_COLORS] || BACKGROUND_COLORS.layer3; + + return ( + + {getCalloutIcon(node, TEXT_COLORS.primary)} + {children} + + ); + }, + + mention: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => { + const id = (node.attrs?.id as string) || ""; + const entityIdentifier = (node.attrs?.entity_identifier as string) || ""; + const entityName = (node.attrs?.entity_name as string) || ""; + + let displayText = entityName || id || entityIdentifier; + + if (ctx.metadata && (entityName === "user_mention" || entityName === "user")) { + const userMention = ctx.metadata.userMentions?.find((u) => u.id === entityIdentifier || u.id === id); + if (userMention) { + displayText = userMention.display_name; + } + } + + return ( + + @{displayText} + + ); + }, +}; + +type InternalRenderContext = { + parentType?: string; + nestingLevel: number; + listItemIndex: number; + textAlign?: string | null; + pdfContext: PDFRenderContext; +}; + +const renderNodeWithContext = (node: TipTapNode, context: InternalRenderContext): ReactElement => { + const { parentType, nestingLevel, listItemIndex, textAlign, pdfContext } = context; + + const isListContainer = node.type === CORE_EXTENSIONS.BULLET_LIST || node.type === CORE_EXTENSIONS.ORDERED_LIST; + + let childTextAlign = textAlign; + if (node.type === CORE_EXTENSIONS.PARAGRAPH && node.attrs?.textAlign) { + childTextAlign = node.attrs.textAlign as string; + } + + const nodeWithContext = { + ...node, + attrs: { + ...node.attrs, + _parentType: parentType, + _nestingLevel: nestingLevel, + _listItemIndex: listItemIndex, + _textAlign: childTextAlign, + _isHeader: node.content?.some((child) => child.type === CORE_EXTENSIONS.TABLE_HEADER), + }, + }; + + let childNestingLevel = nestingLevel; + if (isListContainer && parentType === CORE_EXTENSIONS.LIST_ITEM) { + childNestingLevel = nestingLevel + 1; + } + + let currentListItemIndex = 0; + const children: ReactElement[] = + node.content?.map((child) => { + const childContext: InternalRenderContext = { + parentType: node.type, + nestingLevel: childNestingLevel, + listItemIndex: 0, + textAlign: childTextAlign, + pdfContext, + }; + + if (isListContainer && child.type === CORE_EXTENSIONS.LIST_ITEM) { + currentListItemIndex++; + childContext.listItemIndex = currentListItemIndex; + } + + return renderNodeWithContext(child, childContext); + }) || []; + + const renderer = nodeRenderers[node.type]; + if (renderer) { + return renderer(nodeWithContext, children, pdfContext); + } + + if (children.length > 0) { + return {children}; + } + + return ; +}; + +export const renderNode = ( + node: TipTapNode, + parentType?: string, + _index?: number, + metadata?: PDFExportMetadata, + getKey?: KeyGenerator +): ReactElement => { + const keyGen = getKey ?? createKeyGenerator(); + + return renderNodeWithContext(node, { + parentType, + nestingLevel: 0, + listItemIndex: 0, + pdfContext: { getKey: keyGen, metadata }, + }); +}; diff --git a/plane-src/apps/live/src/lib/pdf/plane-pdf-exporter.tsx b/plane-src/apps/live/src/lib/pdf/plane-pdf-exporter.tsx new file mode 100644 index 0000000..f6c6b59 --- /dev/null +++ b/plane-src/apps/live/src/lib/pdf/plane-pdf-exporter.tsx @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { createRequire } from "module"; +import path from "path"; +import { Document, Font, Page, pdf, Text } from "@react-pdf/renderer"; +import { createKeyGenerator, renderNode } from "./node-renderers"; +import { pdfStyles } from "./styles"; +import type { PDFExportOptions, TipTapDocument } from "./types"; + +// Use createRequire for ESM compatibility to resolve font file paths +const require = createRequire(import.meta.url); + +// Resolve local font file paths from @fontsource/inter package +const interFontDir = path.dirname(require.resolve("@fontsource/inter/package.json")); + +Font.register({ + family: "Inter", + fonts: [ + { + src: path.join(interFontDir, "files/inter-latin-400-normal.woff"), + fontWeight: 400, + }, + { + src: path.join(interFontDir, "files/inter-latin-400-italic.woff"), + fontWeight: 400, + fontStyle: "italic", + }, + { + src: path.join(interFontDir, "files/inter-latin-600-normal.woff"), + fontWeight: 600, + }, + { + src: path.join(interFontDir, "files/inter-latin-600-italic.woff"), + fontWeight: 600, + fontStyle: "italic", + }, + { + src: path.join(interFontDir, "files/inter-latin-700-normal.woff"), + fontWeight: 700, + }, + { + src: path.join(interFontDir, "files/inter-latin-700-italic.woff"), + fontWeight: 700, + fontStyle: "italic", + }, + ], +}); + +export const createPdfDocument = (doc: TipTapDocument, options: PDFExportOptions = {}) => { + const { title, author, subject, pageSize = "A4", pageOrientation = "portrait", metadata, noAssets } = options; + + // Merge noAssets into metadata for use in node renderers + const mergedMetadata = { ...metadata, noAssets }; + + const content = doc.content || []; + const getKey = createKeyGenerator(); + const renderedContent = content.map((node, index) => renderNode(node, "doc", index, mergedMetadata, getKey)); + + return ( + + + {title && {title}} + {renderedContent} + + + ); +}; + +export const renderPlaneDocToPdfBuffer = async ( + doc: TipTapDocument, + options: PDFExportOptions = {} +): Promise => { + const pdfDocument = createPdfDocument(doc, options); + const pdfInstance = pdf(pdfDocument); + const blob = await pdfInstance.toBlob(); + const arrayBuffer = await blob.arrayBuffer(); + return Buffer.from(arrayBuffer); +}; + +export const renderPlaneDocToPdfBlob = async (doc: TipTapDocument, options: PDFExportOptions = {}): Promise => { + const pdfDocument = createPdfDocument(doc, options); + const pdfInstance = pdf(pdfDocument); + return await pdfInstance.toBlob(); +}; diff --git a/plane-src/apps/live/src/lib/pdf/styles.ts b/plane-src/apps/live/src/lib/pdf/styles.ts new file mode 100644 index 0000000..b55a156 --- /dev/null +++ b/plane-src/apps/live/src/lib/pdf/styles.ts @@ -0,0 +1,250 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { StyleSheet } from "@react-pdf/renderer"; +import { + BACKGROUND_COLORS, + BORDER_COLORS, + BRAND_COLORS, + CODE_COLORS, + LINK_COLORS, + MENTION_COLORS, + TEXT_COLORS, +} from "./colors"; + +export const pdfStyles = StyleSheet.create({ + page: { + padding: 40, + fontFamily: "Inter", + fontSize: 11, + lineHeight: 1.6, + color: TEXT_COLORS.primary, + }, + title: { + fontSize: 24, + fontWeight: 600, + marginBottom: 20, + color: TEXT_COLORS.primary, + }, + heading1: { + fontSize: 20, + fontWeight: 600, + marginTop: 16, + marginBottom: 8, + color: TEXT_COLORS.primary, + }, + heading2: { + fontSize: 16, + fontWeight: 600, + marginTop: 14, + marginBottom: 6, + color: TEXT_COLORS.primary, + }, + heading3: { + fontSize: 14, + fontWeight: 600, + marginTop: 12, + marginBottom: 4, + color: TEXT_COLORS.primary, + }, + heading4: { + fontSize: 12, + fontWeight: 600, + marginTop: 10, + marginBottom: 4, + color: TEXT_COLORS.secondary, + }, + heading5: { + fontSize: 11, + fontWeight: 600, + marginTop: 8, + marginBottom: 4, + color: TEXT_COLORS.secondary, + }, + heading6: { + fontSize: 10, + fontWeight: 600, + marginTop: 6, + marginBottom: 4, + color: TEXT_COLORS.tertiary, + }, + paragraph: { + marginBottom: 0, + }, + paragraphWrapper: { + marginBottom: 8, + }, + blockquote: { + borderLeftWidth: 3, + borderLeftColor: BORDER_COLORS.strong, // Matches .ProseMirror blockquote border-strong + paddingLeft: 12, + marginLeft: 0, + marginVertical: 8, + fontStyle: "normal", // Matches editor: font-style: normal + fontWeight: 400, // Matches editor: font-weight: 400 + color: TEXT_COLORS.primary, + breakInside: "avoid", + }, + codeBlock: { + backgroundColor: BACKGROUND_COLORS.layer1, // bg-layer-1 equivalent + padding: 12, + borderRadius: 4, + fontFamily: "Courier", + fontSize: 10, + marginVertical: 8, + color: TEXT_COLORS.primary, + breakInside: "avoid", + }, + codeInline: { + backgroundColor: BACKGROUND_COLORS.layer1, + padding: 2, + paddingHorizontal: 4, + borderRadius: 2, + fontFamily: "Courier", + fontSize: 10, + color: CODE_COLORS.text, // Red for inline code + }, + bulletList: { + marginVertical: 8, + paddingLeft: 0, + }, + orderedList: { + marginVertical: 8, + paddingLeft: 0, + }, + listItem: { + display: "flex", + flexDirection: "row", + gap: 6, + marginBottom: 4, + paddingRight: 10, + breakInside: "avoid", + }, + listItemBullet: {}, + listItemContent: { + flex: 1, + }, + taskList: { + marginVertical: 8, + }, + taskItem: { + display: "flex", + flexDirection: "row", + gap: 6, + marginBottom: 4, + alignItems: "flex-start", + paddingRight: 10, + breakInside: "avoid", + }, + taskCheckbox: { + width: 12, + height: 12, + borderWidth: 1, + borderColor: BORDER_COLORS.strong, // Matches editor: border-strong + borderRadius: 2, + marginTop: 2, + alignItems: "center", + justifyContent: "center", + }, + taskCheckboxChecked: { + backgroundColor: BRAND_COLORS.default, // --background-color-accent-primary + borderColor: BRAND_COLORS.default, // --border-color-accent-strong + }, + table: { + marginVertical: 8, + borderWidth: 1, + borderColor: BORDER_COLORS.subtle1, // border-subtle-1 + }, + tableRow: { + flexDirection: "row", + borderBottomWidth: 1, + borderBottomColor: BORDER_COLORS.subtle1, + breakInside: "avoid", + }, + tableHeaderRow: { + backgroundColor: BACKGROUND_COLORS.surface2, // Slightly different from white + flexDirection: "row", + borderBottomWidth: 1, + borderBottomColor: BORDER_COLORS.subtle1, + }, + tableCell: { + padding: 8, + borderRightWidth: 1, + borderRightColor: BORDER_COLORS.subtle1, + flex: 1, + }, + tableHeaderCell: { + padding: 8, + borderRightWidth: 1, + borderRightColor: BORDER_COLORS.subtle1, + flex: 1, + fontWeight: "bold", + }, + horizontalRule: { + borderBottomWidth: 1, + borderBottomColor: BORDER_COLORS.subtle1, // Matches div[data-type="horizontalRule"] border-subtle-1 + marginVertical: 16, + }, + image: { + maxWidth: "100%", + marginVertical: 8, + }, + imagePlaceholder: { + backgroundColor: BACKGROUND_COLORS.layer1, + padding: 16, + borderRadius: 4, + marginVertical: 8, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: BORDER_COLORS.subtle, + borderStyle: "dashed", + }, + imagePlaceholderText: { + color: TEXT_COLORS.tertiary, + fontSize: 10, + }, + callout: { + backgroundColor: BACKGROUND_COLORS.layer3, // bg-layer-3 (default callout background) + padding: 12, + borderRadius: 6, + marginVertical: 8, + flexDirection: "row", + alignItems: "flex-start", + breakInside: "avoid", + }, + calloutIconContainer: { + marginRight: 10, + marginTop: 2, + }, + calloutContent: { + flex: 1, + color: TEXT_COLORS.primary, // text-primary + }, + mention: { + backgroundColor: MENTION_COLORS.background, // bg-accent-primary/20 equivalent + color: MENTION_COLORS.text, // text-accent-primary + padding: 2, + paddingHorizontal: 4, + borderRadius: 2, + }, + link: { + color: LINK_COLORS.primary, // --txt-link-primary + textDecoration: "underline", + }, + bold: { + fontWeight: "bold", + }, + italic: { + fontStyle: "italic", + }, + underline: { + textDecoration: "underline", + }, + strike: { + textDecoration: "line-through", + }, +}); diff --git a/plane-src/apps/live/src/lib/pdf/types.ts b/plane-src/apps/live/src/lib/pdf/types.ts new file mode 100644 index 0000000..0578a49 --- /dev/null +++ b/plane-src/apps/live/src/lib/pdf/types.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Style } from "@react-pdf/types"; + +export type TipTapMark = { + type: string; + attrs?: Record; +}; + +export type TipTapNode = { + type: string; + attrs?: Record; + content?: TipTapNode[]; + text?: string; + marks?: TipTapMark[]; +}; + +export type TipTapDocument = { + type: "doc"; + content?: TipTapNode[]; +}; + +export type KeyGenerator = () => string; + +export type PDFRenderContext = { + getKey: KeyGenerator; + metadata?: PDFExportMetadata; +}; + +export type PDFNodeRenderer = ( + node: TipTapNode, + children: React.ReactElement[], + context: PDFRenderContext +) => React.ReactElement; + +export type PDFMarkRenderer = (mark: TipTapMark, currentStyle: Style) => Style; + +export type NodeRendererRegistry = Record; + +export type MarkRendererRegistry = Record; + +export type PDFExportOptions = { + title?: string; + author?: string; + subject?: string; + pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID"; + pageOrientation?: "portrait" | "landscape"; + metadata?: PDFExportMetadata; + /** When true, images and other assets are excluded from the PDF */ + noAssets?: boolean; +}; + +/** + * Metadata for resolving entity references in PDF export + */ +export type PDFExportMetadata = { + /** User mentions (user_mention in mention node) */ + userMentions?: PDFUserMention[]; + /** Resolved image URLs: Map of asset ID to presigned URL */ + resolvedImageUrls?: Record; + /** When true, images and other assets are excluded from the PDF */ + noAssets?: boolean; +}; + +export type PDFUserMention = { + id: string; + display_name: string; + avatar_url?: string; +}; diff --git a/plane-src/apps/live/src/lib/stateless.ts b/plane-src/apps/live/src/lib/stateless.ts new file mode 100644 index 0000000..d59f8fe --- /dev/null +++ b/plane-src/apps/live/src/lib/stateless.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { onStatelessPayload } from "@hocuspocus/server"; +import { DocumentCollaborativeEvents } from "@plane/editor/lib"; +import type { TDocumentEventsServer } from "@plane/editor/lib"; + +/** + * Broadcast the client event to all the clients so that they can update their state + * @param param0 + */ +export const onStateless = async ({ payload, document }: onStatelessPayload) => { + const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer]?.client; + if (response) { + document.broadcastStateless(response); + } +}; diff --git a/plane-src/apps/live/src/redis.ts b/plane-src/apps/live/src/redis.ts new file mode 100644 index 0000000..f23374a --- /dev/null +++ b/plane-src/apps/live/src/redis.ts @@ -0,0 +1,220 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import Redis from "ioredis"; +import { logger } from "@plane/logger"; +import { env } from "./env"; + +export class RedisManager { + private static instance: RedisManager; + private redisClient: Redis | null = null; + private isConnected: boolean = false; + private connectionPromise: Promise | null = null; + + private constructor() {} + + public static getInstance(): RedisManager { + if (!RedisManager.instance) { + RedisManager.instance = new RedisManager(); + } + return RedisManager.instance; + } + + public async initialize(): Promise { + if (this.redisClient && this.isConnected) { + logger.info("REDIS_MANAGER: client already initialized and connected"); + return; + } + + if (this.connectionPromise) { + logger.info("REDIS_MANAGER: Redis connection already in progress, waiting..."); + await this.connectionPromise; + return; + } + + this.connectionPromise = this.connect(); + await this.connectionPromise; + } + + private getRedisUrl(): string { + const redisUrl = env.REDIS_URL; + const redisHost = env.REDIS_HOST; + const redisPort = env.REDIS_PORT; + + if (redisUrl) { + return redisUrl; + } + + if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) { + return `redis://${redisHost}:${redisPort}`; + } + + return ""; + } + + private async connect(): Promise { + try { + const redisUrl = this.getRedisUrl(); + + if (!redisUrl) { + logger.warn("REDIS_MANAGER: No Redis URL provided, Redis functionality will be disabled"); + this.isConnected = false; + return; + } + + // Configuration optimized for BOTH regular operations AND pub/sub + // HocuspocusRedis uses .duplicate() which inherits these settings + this.redisClient = new Redis(redisUrl, { + lazyConnect: false, // Connect immediately for reliability (duplicates inherit this) + keepAlive: 30000, + connectTimeout: 10000, + maxRetriesPerRequest: 3, + enableOfflineQueue: true, // Keep commands queued during reconnection + retryStrategy: (times: number) => { + // Exponential backoff with max 2 seconds + const delay = Math.min(times * 50, 2000); + logger.info(`REDIS_MANAGER: Reconnection attempt ${times}, delay: ${delay}ms`); + return delay; + }, + }); + + // Set up event listeners + this.redisClient.on("connect", () => { + logger.info("REDIS_MANAGER: Redis client connected"); + this.isConnected = true; + }); + + this.redisClient.on("ready", () => { + logger.info("REDIS_MANAGER: Redis client ready"); + this.isConnected = true; + }); + + this.redisClient.on("error", (error) => { + logger.error("REDIS_MANAGER: Redis client error:", error); + this.isConnected = false; + }); + + this.redisClient.on("close", () => { + logger.warn("REDIS_MANAGER: Redis client connection closed"); + this.isConnected = false; + }); + + this.redisClient.on("reconnecting", () => { + logger.info("REDIS_MANAGER: Redis client reconnecting..."); + this.isConnected = false; + }); + + await this.redisClient.ping(); + logger.info("REDIS_MANAGER: Redis connection test successful"); + } catch (error) { + logger.error("REDIS_MANAGER: Failed to initialize Redis client:", error); + this.isConnected = false; + throw error; + } finally { + this.connectionPromise = null; + } + } + + public getClient(): Redis | null { + if (!this.redisClient || !this.isConnected) { + logger.warn("REDIS_MANAGER: Redis client not available or not connected"); + return null; + } + return this.redisClient; + } + + public isClientConnected(): boolean { + return this.isConnected && this.redisClient !== null; + } + + public async disconnect(): Promise { + if (this.redisClient) { + try { + await this.redisClient.quit(); + logger.info("REDIS_MANAGER: Redis client disconnected gracefully"); + } catch (error) { + logger.error("REDIS_MANAGER: Error disconnecting Redis client:", error); + // Force disconnect if quit fails + this.redisClient.disconnect(); + } finally { + this.redisClient = null; + this.isConnected = false; + } + } + } + + // Convenience methods for common Redis operations + public async set(key: string, value: string, ttl?: number): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + if (ttl) { + await client.setex(key, ttl, value); + } else { + await client.set(key, value); + } + return true; + } catch (error) { + logger.error(`REDIS_MANAGER: Error setting Redis key ${key}:`, error); + return false; + } + } + + public async get(key: string): Promise { + const client = this.getClient(); + if (!client) return null; + + try { + return await client.get(key); + } catch (error) { + logger.error(`REDIS_MANAGER: Error getting Redis key ${key}:`, error); + return null; + } + } + + public async del(key: string): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + await client.del(key); + return true; + } catch (error) { + logger.error(`REDIS_MANAGER: Error deleting Redis key ${key}:`, error); + return false; + } + } + + public async exists(key: string): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + const result = await client.exists(key); + return result === 1; + } catch (error) { + logger.error(`REDIS_MANAGER: Error checking Redis key ${key}:`, error); + return false; + } + } + + public async expire(key: string, ttl: number): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + const result = await client.expire(key, ttl); + return result === 1; + } catch (error) { + logger.error(`REDIS_MANAGER: Error setting expiry for Redis key ${key}:`, error); + return false; + } + } +} + +// Export a default instance for convenience +export const redisManager = RedisManager.getInstance(); diff --git a/plane-src/apps/live/src/schema/pdf-export.ts b/plane-src/apps/live/src/schema/pdf-export.ts new file mode 100644 index 0000000..e3085ee --- /dev/null +++ b/plane-src/apps/live/src/schema/pdf-export.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Schema } from "effect"; + +export const PdfExportRequestBody = Schema.Struct({ + pageId: Schema.NonEmptyTrimmedString, + workspaceSlug: Schema.NonEmptyTrimmedString, + projectId: Schema.optional(Schema.NonEmptyTrimmedString), + title: Schema.optional(Schema.String), + author: Schema.optional(Schema.String), + subject: Schema.optional(Schema.String), + pageSize: Schema.optional(Schema.Literal("A4", "A3", "A2", "LETTER", "LEGAL", "TABLOID")), + pageOrientation: Schema.optional(Schema.Literal("portrait", "landscape")), + fileName: Schema.optional(Schema.String), + noAssets: Schema.optional(Schema.Boolean), +}); + +export type TPdfExportRequestBody = Schema.Schema.Type; + +export class PdfValidationError extends Schema.TaggedError()("PdfValidationError", { + message: Schema.NonEmptyTrimmedString, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfAuthenticationError extends Schema.TaggedError()("PdfAuthenticationError", { + message: Schema.NonEmptyTrimmedString, +}) {} + +export class PdfContentFetchError extends Schema.TaggedError()("PdfContentFetchError", { + message: Schema.NonEmptyTrimmedString, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfMetadataFetchError extends Schema.TaggedError()("PdfMetadataFetchError", { + message: Schema.NonEmptyTrimmedString, + source: Schema.Literal("user-mentions"), + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfImageProcessingError extends Schema.TaggedError()("PdfImageProcessingError", { + message: Schema.NonEmptyTrimmedString, + assetId: Schema.NonEmptyTrimmedString, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfGenerationError extends Schema.TaggedError()("PdfGenerationError", { + message: Schema.NonEmptyTrimmedString, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class PdfTimeoutError extends Schema.TaggedError()("PdfTimeoutError", { + message: Schema.NonEmptyTrimmedString, + operation: Schema.NonEmptyTrimmedString, +}) {} + +export type PdfExportError = + | PdfValidationError + | PdfAuthenticationError + | PdfContentFetchError + | PdfMetadataFetchError + | PdfImageProcessingError + | PdfGenerationError + | PdfTimeoutError; diff --git a/plane-src/apps/live/src/server.ts b/plane-src/apps/live/src/server.ts new file mode 100644 index 0000000..9a3906b --- /dev/null +++ b/plane-src/apps/live/src/server.ts @@ -0,0 +1,128 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Server as HttpServer } from "http"; +import type { Hocuspocus } from "@hocuspocus/server"; +import compression from "compression"; +import cors from "cors"; +import type { Express, Request, Response, Router } from "express"; +import express from "express"; +import expressWs from "express-ws"; +import helmet from "helmet"; +// plane imports +import { registerController } from "@plane/decorators"; +import { logger, loggerMiddleware } from "@plane/logger"; +// controllers +import { CONTROLLERS } from "@/controllers"; +// env +import { env } from "@/env"; +// hocuspocus server +import { HocusPocusServerManager } from "@/hocuspocus"; +// redis +import { redisManager } from "@/redis"; + +export class Server { + private app: Express; + private router: Router; + private hocuspocusServer: Hocuspocus | undefined; + private httpServer: HttpServer | undefined; + + constructor() { + this.app = express(); + expressWs(this.app); + this.setupMiddleware(); + this.router = express.Router(); + this.app.set("port", env.PORT || 3000); + this.app.use(env.LIVE_BASE_PATH, this.router); + } + + public async initialize(): Promise { + try { + await redisManager.initialize(); + logger.info("SERVER: Redis setup completed"); + const manager = HocusPocusServerManager.getInstance(); + this.hocuspocusServer = await manager.initialize(); + logger.info("SERVER: HocusPocus setup completed"); + this.setupRoutes(this.hocuspocusServer); + this.setupNotFoundHandler(); + } catch (error) { + logger.error("SERVER: Failed to initialize live server dependencies:", error); + throw error; + } + } + + private setupMiddleware() { + // Security middleware + this.app.use(helmet()); + // Middleware for response compression + this.app.use(compression({ level: env.COMPRESSION_LEVEL, threshold: env.COMPRESSION_THRESHOLD })); + // Logging middleware + this.app.use(loggerMiddleware); + // Body parsing middleware + this.app.use(express.json()); + this.app.use(express.urlencoded({ extended: true })); + // cors middleware + this.setupCors(); + } + + private setupCors() { + const allowedOrigins = env.CORS_ALLOWED_ORIGINS.split(",").map((s) => s.trim()); + this.app.use( + cors({ + origin: allowedOrigins.length > 0 ? allowedOrigins : false, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "x-api-key"], + }) + ); + } + + private setupNotFoundHandler() { + this.app.use((_req: Request, res: Response) => { + res.status(404).json({ + message: "Not Found", + }); + }); + } + + private setupRoutes(hocuspocusServer: Hocuspocus) { + CONTROLLERS.forEach((controller) => registerController(this.router, controller, [hocuspocusServer])); + } + + public listen() { + this.httpServer = this.app + .listen(this.app.get("port"), () => { + logger.info(`SERVER: Express server has started at port ${this.app.get("port")}`); + }) + .on("error", (err) => { + logger.error("SERVER: Failed to start server:", err); + throw err; + }); + } + + public async destroy() { + if (this.hocuspocusServer) { + this.hocuspocusServer.closeConnections(); + logger.info("SERVER: HocusPocus connections closed gracefully."); + } + + await redisManager.disconnect(); + logger.info("SERVER: Redis connection closed gracefully."); + + if (this.httpServer) { + await new Promise((resolve, reject) => { + this.httpServer!.close((err) => { + if (err) { + reject(err); + } else { + logger.info("SERVER: Express server closed gracefully."); + resolve(); + } + }); + }); + } + } +} diff --git a/plane-src/apps/live/src/services/api.service.ts b/plane-src/apps/live/src/services/api.service.ts new file mode 100644 index 0000000..3834bbd --- /dev/null +++ b/plane-src/apps/live/src/services/api.service.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { AxiosInstance } from "axios"; +import axios from "axios"; +import { env } from "@/env"; +import { AppError } from "@/lib/errors"; + +export abstract class APIService { + protected baseURL: string; + private axiosInstance: AxiosInstance; + private header: Record = {}; + + constructor(baseURL?: string) { + this.baseURL = baseURL || env.API_BASE_URL; + this.axiosInstance = axios.create({ + baseURL: this.baseURL, + withCredentials: true, + timeout: 20000, + }); + this.setupInterceptors(); + } + + private setupInterceptors() { + this.axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + return Promise.reject(new AppError(error)); + } + ); + } + + setHeader(key: string, value: string) { + this.header[key] = value; + } + + getHeader() { + return this.header; + } + + get(url: string, params = {}, config = {}) { + return this.axiosInstance.get(url, { + ...params, + ...config, + }); + } + + post(url: string, data = {}, config = {}) { + return this.axiosInstance.post(url, data, config); + } + + put(url: string, data = {}, config = {}) { + return this.axiosInstance.put(url, data, config); + } + + patch(url: string, data = {}, config = {}) { + return this.axiosInstance.patch(url, data, config); + } + + delete(url: string, data?: Record | null | string, config = {}) { + return this.axiosInstance.delete(url, { data, ...config }); + } + + request(config = {}) { + return this.axiosInstance(config); + } +} diff --git a/plane-src/apps/live/src/services/page/core.service.ts b/plane-src/apps/live/src/services/page/core.service.ts new file mode 100644 index 0000000..c3f3242 --- /dev/null +++ b/plane-src/apps/live/src/services/page/core.service.ts @@ -0,0 +1,227 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { logger } from "@plane/logger"; +import type { TDocumentPayload, TPage } from "@plane/types"; +// services +import { AppError } from "@/lib/errors"; +import { APIService } from "../api.service"; + +export type TUserMention = { + id: string; + display_name: string; + avatar_url?: string; +}; + +export abstract class PageCoreService extends APIService { + protected abstract basePath: string; + + constructor() { + super(); + } + + async fetchDetails(pageId: string): Promise { + try { + const response = await this.get(`${this.basePath}/pages/${pageId}/`, { + headers: this.getHeader(), + }); + return response?.data as TPage; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "fetchDetails", pageId }, + }); + logger.error("Failed to fetch page details", appError); + throw appError; + } + } + + async fetchDescriptionBinary(pageId: string): Promise { + try { + const response = await this.get(`${this.basePath}/pages/${pageId}/description/`, { + headers: { + ...this.getHeader(), + "Content-Type": "application/octet-stream", + }, + responseType: "arraybuffer", + }); + const data = response?.data; + if (!Buffer.isBuffer(data)) { + throw new Error("Expected response to be a Buffer"); + } + return data; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "fetchDescriptionBinary", pageId }, + }); + logger.error("Failed to fetch page description binary", appError); + throw appError; + } + } + + /** + * Updates the title of a page + */ + async updatePageProperties( + pageId: string, + params: { data: Partial; abortSignal?: AbortSignal } + ): Promise { + const { data, abortSignal } = params; + + // Early abort check + if (abortSignal?.aborted) { + throw new AppError(new DOMException("Aborted", "AbortError")); + } + + // Create an abort listener that will reject the pending promise + let abortListener: (() => void) | undefined; + const abortPromise = new Promise((_, reject) => { + if (abortSignal) { + abortListener = () => { + reject(new AppError(new DOMException("Aborted", "AbortError"))); + }; + abortSignal.addEventListener("abort", abortListener); + } + }); + + try { + return await Promise.race([ + this.patch(`${this.basePath}/pages/${pageId}/`, data, { + headers: this.getHeader(), + signal: abortSignal, + }) + .then((response) => response?.data) + .catch((error) => { + const appError = new AppError(error, { + context: { operation: "updatePageProperties", pageId }, + }); + + if (appError.code === "ABORT_ERROR") { + throw appError; + } + + logger.error("Failed to update page properties", appError); + throw appError; + }), + abortPromise, + ]); + } finally { + // Clean up abort listener + if (abortSignal && abortListener) { + abortSignal.removeEventListener("abort", abortListener); + } + } + } + + async updateDescriptionBinary(pageId: string, data: TDocumentPayload): Promise { + try { + const response = await this.patch(`${this.basePath}/pages/${pageId}/description/`, data, { + headers: this.getHeader(), + }); + return response?.data as unknown; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "updateDescriptionBinary", pageId }, + }); + logger.error("Failed to update page description binary", appError); + throw appError; + } + } + + /** + * Fetches user mentions for a page + * @param pageId - The page ID + * @returns Array of user mentions + */ + async fetchUserMentions(pageId: string): Promise { + try { + const response = await this.get(`${this.basePath}/pages/${pageId}/mentions/`, { + headers: this.getHeader(), + params: { + mention_type: "user_mention", + }, + }); + return (response?.data as TUserMention[]) ?? []; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "fetchUserMentions", pageId }, + }); + logger.error("Failed to fetch user mentions", appError); + throw appError; + } + } + + /** + * Resolves an image asset ID to its actual URL by following the 302 redirect + * @param workspaceSlug - The workspace slug + * @param assetId - The asset UUID + * @param projectId - Optional project ID for project-specific assets + * @returns The resolved image URL (presigned S3 URL) + */ + async resolveImageAssetUrl( + workspaceSlug: string, + assetId: string, + projectId?: string | null + ): Promise { + const path = projectId + ? `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/?disposition=inline` + : `/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/?disposition=inline`; + + try { + const response = await this.get(path, { + headers: this.getHeader(), + maxRedirects: 0, + validateStatus: (status: number) => status >= 200 && status < 400, + }); + // If we get a 302, the Location header contains the presigned URL + if (response.status === 302 || response.status === 301) { + return response.headers?.location || null; + } + return null; + } catch (error) { + // Axios throws on 3xx when maxRedirects is 0, so we need to handle the redirect from the error + if ((error as any).response?.status === 302 || (error as any).response?.status === 301) { + return (error as any).response.headers?.location || null; + } + logger.error("Failed to resolve image asset URL", { + assetId, + workspaceSlug, + error: (error as any).message, + }); + return null; + } + } + + /** + * Resolves multiple image asset IDs to their actual URLs + * @param workspaceSlug - The workspace slug + * @param assetIds - Array of asset UUIDs + * @param projectId - Optional project ID for project-specific assets + * @returns Map of assetId to resolved URL + */ + async resolveImageAssetUrls( + workspaceSlug: string, + assetIds: string[], + projectId?: string | null + ): Promise> { + const urlMap = new Map(); + + // Resolve all asset URLs in parallel + const results = await Promise.allSettled( + assetIds.map(async (assetId) => { + const url = await this.resolveImageAssetUrl(workspaceSlug, assetId, projectId); + return { assetId, url }; + }) + ); + + for (const result of results) { + if (result.status === "fulfilled" && result.value.url) { + urlMap.set(result.value.assetId, result.value.url); + } + } + + return urlMap; + } +} diff --git a/plane-src/apps/live/src/services/page/extended.service.ts b/plane-src/apps/live/src/services/page/extended.service.ts new file mode 100644 index 0000000..2b076ef --- /dev/null +++ b/plane-src/apps/live/src/services/page/extended.service.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { PageCoreService } from "./core.service"; + +/** + * This is the extended service for the page service. + * It extends the core service and adds additional functionality. + * Implementation for this is found in the enterprise repository. + */ +export abstract class PageService extends PageCoreService { + constructor() { + super(); + } +} diff --git a/plane-src/apps/live/src/services/page/handler.ts b/plane-src/apps/live/src/services/page/handler.ts new file mode 100644 index 0000000..2bfd0b1 --- /dev/null +++ b/plane-src/apps/live/src/services/page/handler.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { AppError } from "@/lib/errors"; +import type { HocusPocusServerContext, TDocumentTypes } from "@/types"; +// services +import { ProjectPageService } from "./project-page.service"; + +export const getPageService = (documentType: TDocumentTypes, context: HocusPocusServerContext) => { + if (documentType === "project_page") { + return new ProjectPageService({ + workspaceSlug: context.workspaceSlug, + projectId: context.projectId, + cookie: context.cookie, + }); + } + + throw new AppError(`Invalid document type ${documentType} provided.`); +}; diff --git a/plane-src/apps/live/src/services/page/project-page.service.ts b/plane-src/apps/live/src/services/page/project-page.service.ts new file mode 100644 index 0000000..d89ab0a --- /dev/null +++ b/plane-src/apps/live/src/services/page/project-page.service.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { AppError } from "@/lib/errors"; +import { PageService } from "./extended.service"; + +interface ProjectPageServiceParams { + workspaceSlug: string | null; + projectId: string | null; + cookie: string | null; + [key: string]: unknown; +} + +export class ProjectPageService extends PageService { + protected basePath: string; + + constructor(params: ProjectPageServiceParams) { + super(); + const { workspaceSlug, projectId } = params; + if (!workspaceSlug || !projectId) throw new AppError("Missing required fields."); + // validate cookie + if (!params.cookie) throw new AppError("Cookie is required."); + // set cookie + this.setHeader("Cookie", params.cookie); + // set base path + this.basePath = `/api/workspaces/${workspaceSlug}/projects/${projectId}`; + } +} diff --git a/plane-src/apps/live/src/services/pdf-export/effect-utils.ts b/plane-src/apps/live/src/services/pdf-export/effect-utils.ts new file mode 100644 index 0000000..18f40b0 --- /dev/null +++ b/plane-src/apps/live/src/services/pdf-export/effect-utils.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Effect, Duration, Schedule, pipe } from "effect"; +import { PdfTimeoutError } from "@/schema/pdf-export"; + +/** + * Wraps an effect with timeout and exponential backoff retry logic. + * Preserves the environment type R for proper dependency injection. + */ +export const withTimeoutAndRetry = + (operation: string, { timeoutMs = 5000, maxRetries = 2 }: { timeoutMs?: number; maxRetries?: number } = {}) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.timeoutFail({ + duration: Duration.millis(timeoutMs), + onTimeout: () => + new PdfTimeoutError({ + message: `Operation "${operation}" timed out after ${timeoutMs}ms`, + operation, + }), + }), + Effect.retry( + pipe( + Schedule.exponential(Duration.millis(200)), + Schedule.compose(Schedule.recurs(maxRetries)), + Schedule.tapInput((error: E | PdfTimeoutError) => + Effect.logWarning("PDF_EXPORT: Retrying operation", { operation, error }) + ) + ) + ) + ); + +/** + * Recovers from any error with a default fallback value. + * Logs the error before recovering. + */ +export const recoverWithDefault = + (fallback: A) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.tapError((error) => Effect.logWarning("PDF_EXPORT: Operation failed, using fallback", { error })), + Effect.catchAll(() => Effect.succeed(fallback)) + ); + +/** + * Wraps a promise-returning function with proper Effect error handling + */ +export const tryAsync = (fn: () => Promise, onError: (cause: unknown) => E): Effect.Effect => + Effect.tryPromise({ + try: fn, + catch: onError, + }); diff --git a/plane-src/apps/live/src/services/pdf-export/index.ts b/plane-src/apps/live/src/services/pdf-export/index.ts new file mode 100644 index 0000000..fa2a7c6 --- /dev/null +++ b/plane-src/apps/live/src/services/pdf-export/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export { PdfExportService, exportToPdf } from "./pdf-export.service"; +export * from "./effect-utils"; +export * from "./types"; diff --git a/plane-src/apps/live/src/services/pdf-export/pdf-export.service.ts b/plane-src/apps/live/src/services/pdf-export/pdf-export.service.ts new file mode 100644 index 0000000..e9c67fc --- /dev/null +++ b/plane-src/apps/live/src/services/pdf-export/pdf-export.service.ts @@ -0,0 +1,379 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Effect } from "effect"; +import sharp from "sharp"; +import { getAllDocumentFormatsFromDocumentEditorBinaryData } from "@plane/editor/lib"; +import type { PDFExportMetadata, TipTapDocument } from "@/lib/pdf"; +import { renderPlaneDocToPdfBuffer } from "@/lib/pdf"; +import { getPageService } from "@/services/page/handler"; +import type { TDocumentTypes } from "@/types"; +import { + PdfContentFetchError, + PdfGenerationError, + PdfImageProcessingError, + PdfTimeoutError, +} from "@/schema/pdf-export"; +import { withTimeoutAndRetry, recoverWithDefault, tryAsync } from "./effect-utils"; +import type { PdfExportInput, PdfExportResult, PageContent, MetadataResult } from "./types"; + +const IMAGE_CONCURRENCY = 4; +const IMAGE_TIMEOUT_MS = 8000; +const CONTENT_FETCH_TIMEOUT_MS = 7000; +const PDF_RENDER_TIMEOUT_MS = 15000; +const IMAGE_MAX_DIMENSION = 1200; + +type TipTapNode = { + type: string; + attrs?: Record; + content?: TipTapNode[]; +}; + +/** + * PDF Export Service + */ +export class PdfExportService extends Effect.Service()("PdfExportService", { + sync: () => ({ + /** + * Determines document type + */ + getDocumentType: (_input: PdfExportInput): TDocumentTypes => { + return "project_page"; + }, + + /** + * Extracts image asset IDs from document content + */ + extractImageAssetIds: (doc: TipTapNode): string[] => { + const assetIds: string[] = []; + + const traverse = (node: TipTapNode) => { + if ((node.type === "imageComponent" || node.type === "image") && node.attrs?.src) { + const src = node.attrs.src as string; + if (src && !src.startsWith("http") && !src.startsWith("data:")) { + assetIds.push(src); + } + } + if (node.content) { + for (const child of node.content) { + traverse(child); + } + } + }; + + traverse(doc); + return [...new Set(assetIds)]; + }, + + /** + * Fetches page content (description binary) and parses it + */ + fetchPageContent: ( + pageService: ReturnType, + pageId: string, + requestId: string + ): Effect.Effect => + Effect.gen(function* () { + yield* Effect.logDebug("PDF_EXPORT: Fetching page content", { requestId, pageId }); + + const descriptionBinary = yield* tryAsync( + () => pageService.fetchDescriptionBinary(pageId), + (cause) => + new PdfContentFetchError({ + message: "Failed to fetch page content", + cause, + }) + ).pipe( + withTimeoutAndRetry("fetch page content", { + timeoutMs: CONTENT_FETCH_TIMEOUT_MS, + maxRetries: 3, + }) + ); + + if (!descriptionBinary) { + return yield* Effect.fail( + new PdfContentFetchError({ + message: "Page content not found", + }) + ); + } + + const binaryData = new Uint8Array(descriptionBinary); + const { contentJSON, titleHTML } = getAllDocumentFormatsFromDocumentEditorBinaryData(binaryData, true); + + return { + contentJSON: contentJSON as TipTapDocument, + titleHTML: titleHTML || null, + descriptionBinary, + }; + }), + + /** + * Fetches user mentions for the page + */ + fetchUserMentions: ( + pageService: ReturnType, + pageId: string, + requestId: string + ): Effect.Effect => + Effect.gen(function* () { + yield* Effect.logDebug("PDF_EXPORT: Fetching user mentions", { requestId }); + + const userMentionsRaw = yield* tryAsync( + async () => { + if (pageService.fetchUserMentions) { + return await pageService.fetchUserMentions(pageId); + } + return []; + }, + () => [] + ).pipe(recoverWithDefault([] as Array<{ id: string; display_name: string; avatar_url?: string }>)); + + return { + userMentions: userMentionsRaw.map((u) => ({ + id: u.id, + display_name: u.display_name, + avatar_url: u.avatar_url, + })), + }; + }), + + /** + * Resolves and processes images for PDF embedding + */ + processImages: ( + pageService: ReturnType, + workspaceSlug: string, + projectId: string | undefined, + assetIds: string[], + requestId: string + ): Effect.Effect> => + Effect.gen(function* () { + if (assetIds.length === 0) { + return {}; + } + + yield* Effect.logDebug("PDF_EXPORT: Processing images", { + requestId, + count: assetIds.length, + }); + + // Resolve URLs first + const resolvedUrlMap = yield* tryAsync( + async () => { + const urlMap = new Map(); + for (const assetId of assetIds) { + const url = await pageService.resolveImageAssetUrl?.(workspaceSlug, assetId, projectId); + if (url) urlMap.set(assetId, url); + } + return urlMap; + }, + () => new Map() + ).pipe(recoverWithDefault(new Map())); + + if (resolvedUrlMap.size === 0) { + return {}; + } + + // Process each image + const processSingleImage = ([assetId, url]: [string, string]) => + Effect.gen(function* () { + const response = yield* tryAsync( + () => fetch(url), + (cause) => + new PdfImageProcessingError({ + message: "Failed to fetch image", + assetId, + cause, + }) + ); + + if (!response.ok) { + return yield* Effect.fail( + new PdfImageProcessingError({ + message: `Image fetch returned ${response.status}`, + assetId, + }) + ); + } + + const arrayBuffer = yield* tryAsync( + () => response.arrayBuffer(), + (cause) => + new PdfImageProcessingError({ + message: "Failed to read image body", + assetId, + cause, + }) + ); + + const processedBuffer = yield* tryAsync( + () => + sharp(Buffer.from(arrayBuffer)) + .rotate() + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .resize(IMAGE_MAX_DIMENSION, IMAGE_MAX_DIMENSION, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: 85 }) + .toBuffer(), + (cause) => + new PdfImageProcessingError({ + message: "Failed to process image", + assetId, + cause, + }) + ); + + const base64 = processedBuffer.toString("base64"); + return [assetId, `data:image/jpeg;base64,${base64}`] as const; + }).pipe( + withTimeoutAndRetry(`process image ${assetId}`, { + timeoutMs: IMAGE_TIMEOUT_MS, + maxRetries: 1, + }), + Effect.tapError((error) => + Effect.logWarning("PDF_EXPORT: Image processing failed", { + requestId, + assetId, + error, + }) + ), + Effect.catchAll(() => Effect.succeed(null as readonly [string, string] | null)) + ); + + const entries = Array.from(resolvedUrlMap.entries()); + const pairs = yield* Effect.forEach(entries, processSingleImage, { + concurrency: IMAGE_CONCURRENCY, + }); + + const filtered = pairs.filter((p): p is readonly [string, string] => p !== null); + return Object.fromEntries(filtered); + }), + + /** + * Renders document to PDF buffer + */ + renderPdf: ( + contentJSON: TipTapDocument, + metadata: PDFExportMetadata, + options: { + title?: string; + author?: string; + subject?: string; + pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID"; + pageOrientation?: "portrait" | "landscape"; + noAssets?: boolean; + }, + requestId: string + ): Effect.Effect => + Effect.gen(function* () { + yield* Effect.logDebug("PDF_EXPORT: Rendering PDF", { requestId }); + + const pdfBuffer = yield* tryAsync( + () => + renderPlaneDocToPdfBuffer(contentJSON, { + title: options.title, + author: options.author, + subject: options.subject, + pageSize: options.pageSize, + pageOrientation: options.pageOrientation, + metadata, + noAssets: options.noAssets, + }), + (cause) => + new PdfGenerationError({ + message: "Failed to render PDF", + cause, + }) + ).pipe(withTimeoutAndRetry("render PDF", { timeoutMs: PDF_RENDER_TIMEOUT_MS, maxRetries: 0 })); + + yield* Effect.logInfo("PDF_EXPORT: PDF rendered successfully", { + requestId, + size: pdfBuffer.length, + }); + + return pdfBuffer; + }), + }), +}) {} + +/** + * Main export pipeline - orchestrates the entire PDF export process + * Separate function to avoid circular dependency in service definition + */ +export const exportToPdf = ( + input: PdfExportInput +): Effect.Effect => + Effect.gen(function* () { + const service = yield* PdfExportService; + const { requestId, pageId, workspaceSlug, projectId, noAssets } = input; + + yield* Effect.logInfo("PDF_EXPORT: Starting export", { requestId, pageId, workspaceSlug }); + + // Create page service + const documentType = service.getDocumentType(input); + const pageService = getPageService(documentType, { + workspaceSlug, + projectId: projectId || null, + cookie: input.cookie, + documentType, + userId: "", + }); + + // Fetch content + const content = yield* service.fetchPageContent(pageService, pageId, requestId); + + // Extract image asset IDs + const imageAssetIds = service.extractImageAssetIds(content.contentJSON as TipTapNode); + + // Fetch user mentions + let metadata = yield* service.fetchUserMentions(pageService, pageId, requestId); + + // Process images if needed + if (!noAssets && imageAssetIds.length > 0) { + const resolvedImages = yield* service.processImages( + pageService, + workspaceSlug, + projectId, + imageAssetIds, + requestId + ); + metadata = { ...metadata, resolvedImageUrls: resolvedImages }; + } + + yield* Effect.logDebug("PDF_EXPORT: Metadata prepared", { + requestId, + userMentions: metadata.userMentions?.length ?? 0, + resolvedImages: Object.keys(metadata.resolvedImageUrls ?? {}).length, + }); + + // Render PDF + const documentTitle = input.title || content.titleHTML || undefined; + const pdfBuffer = yield* service.renderPdf( + content.contentJSON, + metadata, + { + title: documentTitle, + author: input.author, + subject: input.subject, + pageSize: input.pageSize, + pageOrientation: input.pageOrientation, + noAssets, + }, + requestId + ); + + yield* Effect.logInfo("PDF_EXPORT: Export complete", { + requestId, + pageId, + size: pdfBuffer.length, + }); + + return { + pdfBuffer, + outputFileName: input.fileName || `page-${pageId}.pdf`, + pageId, + }; + }); diff --git a/plane-src/apps/live/src/services/pdf-export/types.ts b/plane-src/apps/live/src/services/pdf-export/types.ts new file mode 100644 index 0000000..1a95b0e --- /dev/null +++ b/plane-src/apps/live/src/services/pdf-export/types.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { TipTapDocument, PDFUserMention } from "@/lib/pdf"; + +export interface PdfExportInput { + readonly pageId: string; + readonly workspaceSlug: string; + readonly projectId?: string; + readonly title?: string; + readonly author?: string; + readonly subject?: string; + readonly pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID"; + readonly pageOrientation?: "portrait" | "landscape"; + readonly fileName?: string; + readonly noAssets?: boolean; + readonly cookie: string; + readonly requestId: string; +} + +export interface PdfExportResult { + readonly pdfBuffer: Buffer; + readonly outputFileName: string; + readonly pageId: string; +} + +export interface PageContent { + readonly contentJSON: TipTapDocument; + readonly titleHTML: string | null; + readonly descriptionBinary: Buffer; +} + +/** + * Metadata - includes user mentions + */ +export interface MetadataResult { + readonly userMentions: PDFUserMention[]; + readonly resolvedImageUrls?: Record; +} diff --git a/plane-src/apps/live/src/services/user.service.ts b/plane-src/apps/live/src/services/user.service.ts new file mode 100644 index 0000000..b4c2859 --- /dev/null +++ b/plane-src/apps/live/src/services/user.service.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// types +import { logger } from "@plane/logger"; +import type { IUser } from "@plane/types"; +// services +import { AppError } from "@/lib/errors"; +import { APIService } from "@/services/api.service"; + +export class UserService extends APIService { + constructor() { + super(); + } + + currentUserConfig() { + return { + url: `${this.baseURL}/api/users/me/`, + }; + } + + async currentUser(cookie: string): Promise { + return this.get("/api/users/me/", { + headers: { + Cookie: cookie, + }, + }) + .then((response) => response?.data) + .catch((error) => { + const appError = new AppError(error, { + context: { operation: "currentUser" }, + }); + logger.error("Failed to fetch current user", appError); + throw appError; + }); + } +} diff --git a/plane-src/apps/live/src/start.ts b/plane-src/apps/live/src/start.ts new file mode 100644 index 0000000..705eb12 --- /dev/null +++ b/plane-src/apps/live/src/start.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +import { Server } from "./server"; + +let server: Server; + +async function startServer() { + server = new Server(); + try { + await server.initialize(); + server.listen(); + } catch (error) { + logger.error("Failed to start server:", error); + process.exit(1); + } +} + +startServer(); + +// Handle process signals +process.on("SIGTERM", async () => { + logger.info("Received SIGTERM signal. Initiating graceful shutdown..."); + try { + if (server) { + await server.destroy(); + } + logger.info("Server shut down gracefully"); + } catch (error) { + logger.error("Error during graceful shutdown:", error); + process.exit(1); + } + process.exit(0); +}); + +process.on("SIGINT", async () => { + logger.info("Received SIGINT signal. Killing node process..."); + try { + if (server) { + await server.destroy(); + } + logger.info("Server shut down gracefully"); + } catch (error) { + logger.error("Error during graceful shutdown:", error); + process.exit(1); + } + process.exit(1); +}); + +process.on("unhandledRejection", (err: Error) => { + const error = new AppError(err); + logger.error(`[UNHANDLED_REJECTION]`, error); +}); + +process.on("uncaughtException", (err: Error) => { + const error = new AppError(err); + logger.error(`[UNCAUGHT_EXCEPTION]`, error); +}); diff --git a/plane-src/apps/live/src/types/admin-commands.ts b/plane-src/apps/live/src/types/admin-commands.ts new file mode 100644 index 0000000..1cbe7a5 --- /dev/null +++ b/plane-src/apps/live/src/types/admin-commands.ts @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +/** + * Type-safe admin commands for server-to-server communication + */ + +/** + * Force close error codes - reasons why a document is being force closed + */ +export enum ForceCloseReason { + CRITICAL_ERROR = "critical_error", + MEMORY_LEAK = "memory_leak", + DOCUMENT_TOO_LARGE = "document_too_large", + ADMIN_REQUEST = "admin_request", + SERVER_SHUTDOWN = "server_shutdown", + SECURITY_VIOLATION = "security_violation", + CORRUPTION_DETECTED = "corruption_detected", +} + +/** + * WebSocket close codes + * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code + */ +export enum CloseCode { + /** Normal closure; the connection successfully completed */ + NORMAL = 1000, + /** The endpoint is going away (server shutdown or browser navigating away) */ + GOING_AWAY = 1001, + /** Protocol error */ + PROTOCOL_ERROR = 1002, + /** Unsupported data */ + UNSUPPORTED_DATA = 1003, + /** Reserved (no status code was present) */ + NO_STATUS = 1005, + /** Abnormal closure */ + ABNORMAL = 1006, + /** Invalid frame payload data */ + INVALID_DATA = 1007, + /** Policy violation */ + POLICY_VIOLATION = 1008, + /** Message too big */ + MESSAGE_TOO_BIG = 1009, + /** Client expected extension not negotiated */ + MANDATORY_EXTENSION = 1010, + /** Server encountered unexpected condition */ + INTERNAL_ERROR = 1011, + /** Custom: Force close requested */ + FORCE_CLOSE = 4000, + /** Custom: Document too large */ + DOCUMENT_TOO_LARGE = 4001, + /** Custom: Memory pressure */ + MEMORY_PRESSURE = 4002, + /** Custom: Security violation */ + SECURITY_VIOLATION = 4003, +} + +/** + * Admin command types + */ +export enum AdminCommand { + FORCE_CLOSE = "force_close", + HEALTH_CHECK = "health_check", + RESTART_DOCUMENT = "restart_document", +} + +/** + * Force close command data structure + */ +export interface ForceCloseCommandData { + command: AdminCommand.FORCE_CLOSE; + docId: string; + reason: ForceCloseReason; + code: CloseCode; + originServer: string; + timestamp?: string; +} + +/** + * Health check command data structure + */ +export interface HealthCheckCommandData { + command: AdminCommand.HEALTH_CHECK; + originServer: string; + timestamp: string; +} + +/** + * Union type for all admin commands + */ +export type AdminCommandData = ForceCloseCommandData | HealthCheckCommandData; + +/** + * Client force close message structure (sent to clients via sendStateless) + */ +export interface ClientForceCloseMessage { + type: "force_close"; + reason: ForceCloseReason; + code: CloseCode; + message?: string; + timestamp?: string; +} + +/** + * Admin command handler function type + */ +export type AdminCommandHandler = (data: T) => Promise | void; + +/** + * Type guard to check if data is a ForceCloseCommandData + */ +export function isForceCloseCommand(data: AdminCommandData): data is ForceCloseCommandData { + return data.command === AdminCommand.FORCE_CLOSE; +} + +/** + * Type guard to check if data is a HealthCheckCommandData + */ +export function isHealthCheckCommand(data: AdminCommandData): data is HealthCheckCommandData { + return data.command === AdminCommand.HEALTH_CHECK; +} + +/** + * Validate force close reason + */ +export function isValidForceCloseReason(reason: string): reason is ForceCloseReason { + return Object.values(ForceCloseReason).includes(reason as ForceCloseReason); +} + +/** + * Get human-readable message for force close reason + */ +export function getForceCloseMessage(reason: ForceCloseReason): string { + const messages: Record = { + [ForceCloseReason.CRITICAL_ERROR]: "A critical error occurred. Please refresh the page.", + [ForceCloseReason.MEMORY_LEAK]: "Memory limit exceeded. Please refresh the page.", + [ForceCloseReason.DOCUMENT_TOO_LARGE]: + "Content limit reached and live sync is off. Create a new page or use nested pages to continue syncing.", + [ForceCloseReason.ADMIN_REQUEST]: "Connection closed by administrator. Please try again later.", + [ForceCloseReason.SERVER_SHUTDOWN]: "Server is shutting down. Please reconnect in a moment.", + [ForceCloseReason.SECURITY_VIOLATION]: "Security violation detected. Connection terminated.", + [ForceCloseReason.CORRUPTION_DETECTED]: "Data corruption detected. Please refresh the page.", + }; + + return messages[reason] || "Connection closed. Please refresh the page."; +} diff --git a/plane-src/apps/live/src/types/index.ts b/plane-src/apps/live/src/types/index.ts new file mode 100644 index 0000000..39c941d --- /dev/null +++ b/plane-src/apps/live/src/types/index.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { fetchPayload, onLoadDocumentPayload, storePayload } from "@hocuspocus/server"; + +export type TConvertDocumentRequestBody = { + description_html: string; + variant: "rich" | "document"; +}; + +export interface OnLoadDocumentPayloadWithContext extends onLoadDocumentPayload { + context: HocusPocusServerContext; +} + +export interface FetchPayloadWithContext extends fetchPayload { + context: HocusPocusServerContext; +} + +export interface StorePayloadWithContext extends storePayload { + context: HocusPocusServerContext; +} + +export type TDocumentTypes = "project_page"; + +// Additional Hocuspocus types that are not exported from the main package +export type HocusPocusServerContext = { + projectId: string | null; + cookie: string; + documentType: TDocumentTypes; + workspaceSlug: string | null; + userId: string; +}; diff --git a/plane-src/apps/live/src/utils/broadcast-error.ts b/plane-src/apps/live/src/utils/broadcast-error.ts new file mode 100644 index 0000000..4d1077c --- /dev/null +++ b/plane-src/apps/live/src/utils/broadcast-error.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Hocuspocus } from "@hocuspocus/server"; +import { createRealtimeEvent } from "@plane/editor"; +import { logger } from "@plane/logger"; +import type { HocusPocusServerContext } from "@/types"; +import { broadcastMessageToPage } from "./broadcast-message"; + +// Helper to broadcast error to frontend +export const broadcastError = async ( + hocuspocusServerInstance: Hocuspocus, + pageId: string, + errorMessage: string, + errorType: "fetch" | "store", + context: HocusPocusServerContext, + errorCode?: "content_too_large" | "page_locked" | "page_archived", + shouldDisconnect?: boolean +) => { + try { + const errorEvent = createRealtimeEvent({ + action: "error", + page_id: pageId, + parent_id: undefined, + descendants_ids: [], + data: { + error_message: errorMessage, + error_type: errorType, + error_code: errorCode, + should_disconnect: shouldDisconnect, + user_id: context.userId || "", + }, + workspace_slug: context.workspaceSlug || "", + user_id: context.userId || "", + }); + + await broadcastMessageToPage(hocuspocusServerInstance, pageId, errorEvent); + } catch (broadcastError) { + logger.error("Error broadcasting error message to frontend:", broadcastError); + } +}; diff --git a/plane-src/apps/live/src/utils/broadcast-message.ts b/plane-src/apps/live/src/utils/broadcast-message.ts new file mode 100644 index 0000000..473a3c7 --- /dev/null +++ b/plane-src/apps/live/src/utils/broadcast-message.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Hocuspocus } from "@hocuspocus/server"; +import type { BroadcastedEvent } from "@plane/editor"; +import { logger } from "@plane/logger"; +import { Redis } from "@/extensions/redis"; +import { AppError } from "@/lib/errors"; + +export const broadcastMessageToPage = async ( + hocuspocusServerInstance: Hocuspocus, + documentName: string, + eventData: BroadcastedEvent +): Promise => { + if (!hocuspocusServerInstance || !hocuspocusServerInstance.documents) { + const appError = new AppError("HocusPocus server not available or initialized", { + context: { operation: "broadcastMessageToPage", documentName }, + }); + logger.error("Error while broadcasting message:", appError); + return false; + } + + const redisExtension = hocuspocusServerInstance.configuration.extensions.find((ext) => ext instanceof Redis); + + if (!redisExtension) { + logger.error("BROADCAST_MESSAGE_TO_PAGE: Redis extension not found"); + return false; + } + + try { + await redisExtension.broadcastToDocument(documentName, eventData); + return true; + } catch (error) { + logger.error(`BROADCAST_MESSAGE_TO_PAGE: Error broadcasting to ${documentName}:`, error); + return false; + } +}; diff --git a/plane-src/apps/live/tests/lib/pdf/pdf-rendering.test.ts b/plane-src/apps/live/tests/lib/pdf/pdf-rendering.test.ts new file mode 100644 index 0000000..507c6f9 --- /dev/null +++ b/plane-src/apps/live/tests/lib/pdf/pdf-rendering.test.ts @@ -0,0 +1,732 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { describe, it, expect } from "vitest"; +import { PDFParse } from "pdf-parse"; +import { renderPlaneDocToPdfBuffer } from "@/lib/pdf"; +import type { TipTapDocument, PDFExportMetadata } from "@/lib/pdf"; + +const PDF_HEADER = "%PDF-"; + +/** + * Helper to extract text content from a PDF buffer + */ +async function extractPdfText(buffer: Buffer): Promise { + const uint8 = new Uint8Array(buffer); + const parser = new PDFParse(uint8); + const result = await parser.getText(); + return result.pages.map((p) => p.text).join("\n"); +} + +describe("PDF Rendering Integration", () => { + describe("renderPlaneDocToPdfBuffer", () => { + it("should render empty document to valid PDF", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER); + }); + + it("should render document with title and verify content", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello World" }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { + title: "Test Document", + }); + + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER); + + const text = await extractPdfText(buffer); + expect(text).toContain("Hello World"); + // Title is rendered in PDF content when provided + expect(text).toContain("Test Document"); + }); + + it("should render heading nodes and verify text", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Main Heading" }], + }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Subheading" }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Main Heading"); + expect(text).toContain("Subheading"); + }); + + it("should render paragraph with text and verify content", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "This is a test paragraph with some content." }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("This is a test paragraph with some content."); + }); + + it("should render bullet list with all items", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "First item" }], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Second item" }], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Third item" }], + }, + ], + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("First item"); + expect(text).toContain("Second item"); + expect(text).toContain("Third item"); + // Bullet points should be present + expect(text).toContain("•"); + }); + + it("should render ordered list with numbers", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "orderedList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Step one" }], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Step two" }], + }, + ], + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Step one"); + expect(text).toContain("Step two"); + // Numbers should be present + expect(text).toMatch(/1\./); + expect(text).toMatch(/2\./); + }); + + it("should render task list with task text", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: true }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Completed task" }], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Pending task" }], + }, + ], + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Completed task"); + expect(text).toContain("Pending task"); + }); + + it("should render code block with code content", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "codeBlock", + content: [ + { type: "text", text: "const greeting = 'Hello';\n" }, + { type: "text", text: "console.log(greeting);" }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("const greeting"); + expect(text).toContain("console.log"); + }); + + it("should render blockquote with quoted text", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "blockquote", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "This is a quoted text." }], + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("This is a quoted text."); + }); + + it("should render table with all cell content", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableHeader", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 1" }], + }, + ], + }, + { + type: "tableHeader", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header 2" }], + }, + ], + }, + ], + }, + { + type: "tableRow", + content: [ + { + type: "tableCell", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 1" }], + }, + ], + }, + { + type: "tableCell", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell 2" }], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Header 1"); + expect(text).toContain("Header 2"); + expect(text).toContain("Cell 1"); + expect(text).toContain("Cell 2"); + }); + + it("should render horizontal rule with surrounding text", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Before rule" }], + }, + { type: "horizontalRule" }, + { + type: "paragraph", + content: [{ type: "text", text: "After rule" }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Before rule"); + expect(text).toContain("After rule"); + }); + + it("should render text with marks (bold, italic) preserving content", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Normal " }, + { + type: "text", + text: "bold", + marks: [{ type: "bold" }], + }, + { type: "text", text: " and " }, + { + type: "text", + text: "italic", + marks: [{ type: "italic" }], + }, + { type: "text", text: " text." }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Normal"); + expect(text).toContain("bold"); + expect(text).toContain("italic"); + expect(text).toContain("text."); + }); + + it("should render link marks with link text", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Click " }, + { + type: "text", + text: "here", + marks: [{ type: "link", attrs: { href: "https://example.com" } }], + }, + { type: "text", text: " to visit." }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Click"); + expect(text).toContain("here"); + expect(text).toContain("to visit"); + }); + }); + + describe("page options", () => { + it("should support different page sizes and verify content renders", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Page size test content" }], + }, + ], + }; + + const a4Buffer = await renderPlaneDocToPdfBuffer(doc, { pageSize: "A4" }); + const letterBuffer = await renderPlaneDocToPdfBuffer(doc, { pageSize: "LETTER" }); + + const a4Text = await extractPdfText(a4Buffer); + const letterText = await extractPdfText(letterBuffer); + + expect(a4Text).toContain("Page size test content"); + expect(letterText).toContain("Page size test content"); + // Different page sizes should produce different PDF sizes + expect(a4Buffer.length).not.toBe(letterBuffer.length); + }); + + it("should support landscape orientation and verify content", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Landscape content here" }], + }, + ], + }; + + const portraitBuffer = await renderPlaneDocToPdfBuffer(doc, { pageOrientation: "portrait" }); + const landscapeBuffer = await renderPlaneDocToPdfBuffer(doc, { pageOrientation: "landscape" }); + + const portraitText = await extractPdfText(portraitBuffer); + const landscapeText = await extractPdfText(landscapeBuffer); + + expect(portraitText).toContain("Landscape content here"); + expect(landscapeText).toContain("Landscape content here"); + expect(portraitBuffer.length).not.toBe(landscapeBuffer.length); + }); + + it("should include author metadata in PDF", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Document content" }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { + author: "Test Author", + }); + + // Verify PDF is valid and contains content + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER); + // Author metadata is embedded in PDF info dict (checked via raw bytes) + const pdfString = buffer.toString("latin1"); + expect(pdfString).toContain("/Author"); + }); + + it("should include subject metadata in PDF", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Document content" }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { + subject: "Technical Documentation", + }); + + // Verify PDF is valid + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER); + // Subject metadata is embedded in PDF info dict + const pdfString = buffer.toString("latin1"); + expect(pdfString).toContain("/Subject"); + }); + }); + + describe("metadata rendering", () => { + it("should render user mentions with resolved display name", async () => { + const metadata: PDFExportMetadata = { + userMentions: [{ id: "user-123", display_name: "John Doe" }], + }; + + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Hello " }, + { + type: "mention", + attrs: { + entity_name: "user_mention", + entity_identifier: "user-123", + }, + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { metadata }); + const text = await extractPdfText(buffer); + + expect(text).toContain("Hello"); + expect(text).toContain("John Doe"); + }); + }); + + describe("complex documents", () => { + it("should render a full document with mixed content and verify all sections", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Project Overview" }], + }, + { + type: "paragraph", + content: [ + { type: "text", text: "This document describes the " }, + { type: "text", text: "key features", marks: [{ type: "bold" }] }, + { type: "text", text: " of the project." }, + ], + }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Features" }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Feature A - Core functionality" }], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Feature B - Advanced options" }], + }, + ], + }, + ], + }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Code Example" }], + }, + { + type: "codeBlock", + content: [{ type: "text", text: "function hello() {\n return 'world';\n}" }], + }, + { + type: "blockquote", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Important: Review before deployment." }], + }, + ], + }, + { type: "horizontalRule" }, + { + type: "paragraph", + content: [{ type: "text", text: "End of document." }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { + title: "Project Overview", + author: "Development Team", + subject: "Technical Documentation", + }); + + const text = await extractPdfText(buffer); + + // Verify metadata is embedded in PDF + const pdfString = buffer.toString("latin1"); + expect(pdfString).toContain("/Title"); + expect(pdfString).toContain("/Author"); + expect(pdfString).toContain("/Subject"); + + // Verify all content sections are present + expect(text).toContain("Project Overview"); + expect(text).toContain("This document describes the"); + expect(text).toContain("key features"); + expect(text).toContain("Features"); + expect(text).toContain("Feature A - Core functionality"); + expect(text).toContain("Feature B - Advanced options"); + expect(text).toContain("Code Example"); + expect(text).toContain("function hello"); + expect(text).toContain("Important: Review before deployment"); + expect(text).toContain("End of document"); + }); + + it("should render deeply nested lists with all levels", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Level 1" }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Level 2" }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Level 3" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Level 1"); + expect(text).toContain("Level 2"); + expect(text).toContain("Level 3"); + }); + }); + + describe("noAssets option", () => { + it("should render text but skip images when noAssets is true", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [ + { + type: "image", + attrs: { src: "https://example.com/image.png" }, + }, + { + type: "paragraph", + content: [{ type: "text", text: "Text after image" }], + }, + ], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { noAssets: true }); + const text = await extractPdfText(buffer); + + expect(text).toContain("Text after image"); + }); + }); +}); diff --git a/plane-src/apps/live/tests/services/pdf-export/effect-utils.test.ts b/plane-src/apps/live/tests/services/pdf-export/effect-utils.test.ts new file mode 100644 index 0000000..44ff35e --- /dev/null +++ b/plane-src/apps/live/tests/services/pdf-export/effect-utils.test.ts @@ -0,0 +1,155 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { describe, it, expect, assert } from "vitest"; +import { Effect, Duration, Either } from "effect"; +import { withTimeoutAndRetry, recoverWithDefault, tryAsync } from "@/services/pdf-export/effect-utils"; +import { PdfTimeoutError } from "@/schema/pdf-export"; + +describe("effect-utils", () => { + describe("withTimeoutAndRetry", () => { + it("should succeed when effect completes within timeout", async () => { + const effect = Effect.succeed("success"); + const wrapped = withTimeoutAndRetry("test-operation")(effect); + + const result = await Effect.runPromise(wrapped); + expect(result).toBe("success"); + }); + + it("should fail with PdfTimeoutError when effect exceeds timeout", async () => { + const slowEffect = Effect.gen(function* () { + yield* Effect.sleep(Duration.millis(500)); + return "success"; + }); + + const wrapped = withTimeoutAndRetry("test-operation", { + timeoutMs: 50, + maxRetries: 0, + })(slowEffect); + + const result = await Effect.runPromise(Effect.either(wrapped)); + + assert(Either.isLeft(result), "Expected Left but got Right"); + expect(result.left).toBeInstanceOf(PdfTimeoutError); + expect((result.left as PdfTimeoutError).operation).toBe("test-operation"); + }); + + it("should retry on failure up to maxRetries times", async () => { + const attemptCounter = { count: 0 }; + + const flakyEffect = Effect.gen(function* () { + attemptCounter.count++; + if (attemptCounter.count < 3) { + return yield* Effect.fail(new Error("transient failure")); + } + return "success"; + }); + + const wrapped = withTimeoutAndRetry("test-operation", { + timeoutMs: 5000, + maxRetries: 3, + })(flakyEffect); + + const result = await Effect.runPromise(wrapped); + + expect(result).toBe("success"); + expect(attemptCounter.count).toBe(3); + }); + + it("should fail after exhausting retries", async () => { + const effect = Effect.fail(new Error("permanent failure")); + + const wrapped = withTimeoutAndRetry("test-operation", { + timeoutMs: 5000, + maxRetries: 2, + })(effect); + + const result = await Effect.runPromise(Effect.either(wrapped)); + + expect(result._tag).toBe("Left"); + }); + }); + + describe("recoverWithDefault", () => { + it("should return success value when effect succeeds", async () => { + const effect = Effect.succeed("success"); + const wrapped = recoverWithDefault("fallback")(effect); + + const result = await Effect.runPromise(wrapped); + expect(result).toBe("success"); + }); + + it("should return fallback value when effect fails", async () => { + const effect = Effect.fail(new Error("failure")); + const wrapped = recoverWithDefault("fallback")(effect); + + const result = await Effect.runPromise(wrapped); + expect(result).toBe("fallback"); + }); + + it("should log warning when recovering from error", async () => { + const logs: string[] = []; + + const effect = Effect.fail(new Error("test error")).pipe( + recoverWithDefault("fallback"), + Effect.tap(() => Effect.sync(() => logs.push("after recovery"))) + ); + + const result = await Effect.runPromise(effect); + + expect(result).toBe("fallback"); + expect(logs).toContain("after recovery"); + }); + + it("should work with complex fallback objects", async () => { + const fallback = { items: [], count: 0, metadata: { version: 1 } }; + + const effect = Effect.fail(new Error("failure")); + const wrapped = recoverWithDefault(fallback)(effect); + + const result = await Effect.runPromise(wrapped); + expect(result).toEqual(fallback); + }); + }); + + describe("tryAsync", () => { + it("should wrap successful promise", async () => { + const effect = tryAsync( + () => Promise.resolve("success"), + (err) => new Error(`wrapped: ${err}`) + ); + + const result = await Effect.runPromise(effect); + expect(result).toBe("success"); + }); + + it("should wrap rejected promise with custom error", async () => { + const effect = tryAsync( + () => Promise.reject(new Error("original")), + (err) => new Error(`wrapped: ${(err as Error).message}`) + ); + + const result = await Effect.runPromise(Effect.either(effect)); + + assert(Either.isLeft(result), "Expected Left but got Right"); + expect(result.left.message).toBe("wrapped: original"); + }); + + it("should handle synchronous throws", async () => { + const effect = tryAsync( + () => { + throw new Error("sync error"); + }, + (err) => new Error(`caught: ${(err as Error).message}`) + ); + + const result = await Effect.runPromise(Effect.either(effect)); + + assert(Either.isLeft(result), "Expected Left but got Right"); + expect(result.left.message).toBe("caught: sync error"); + }); + }); +}); diff --git a/plane-src/apps/live/tsconfig.json b/plane-src/apps/live/tsconfig.json new file mode 100644 index 0000000..9d49437 --- /dev/null +++ b/plane-src/apps/live/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@plane/typescript-config/base.json", + "compilerOptions": { + "exactOptionalPropertyTypes": false, + "noUnusedParameters": false, + "noImplicitOverride": false, + "noImplicitReturns": false, + "noUnusedLocals": false, + "jsx": "react-jsx", + + "paths": { + "@/*": ["./src/*"], + "@/plane-live/*": ["./src/ce/*"] + }, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/plane-src/apps/live/tsdown.config.ts b/plane-src/apps/live/tsdown.config.ts new file mode 100644 index 0000000..e94a584 --- /dev/null +++ b/plane-src/apps/live/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/start.ts"], + outDir: "dist", + format: ["esm"], + dts: false, + clean: true, + sourcemap: false, + exports: true, +}); diff --git a/plane-src/apps/live/vitest.config.ts b/plane-src/apps/live/vitest.config.ts new file mode 100644 index 0000000..d9a1624 --- /dev/null +++ b/plane-src/apps/live/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + include: ["tests/**/*.test.ts", "tests/**/*.spec.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.d.ts", "src/**/types.ts"], + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/plane-src/apps/proxy/.prettierignore b/plane-src/apps/proxy/.prettierignore new file mode 100644 index 0000000..b0b8bc6 --- /dev/null +++ b/plane-src/apps/proxy/.prettierignore @@ -0,0 +1,10 @@ +.next/ +.react-router/ +.turbo/ +.vite/ +build/ +dist/ +node_modules/ +out/ +pnpm-lock.yaml +storybook-static/ diff --git a/plane-src/apps/proxy/Caddyfile.aio.ce b/plane-src/apps/proxy/Caddyfile.aio.ce new file mode 100644 index 0000000..9cf6d8d --- /dev/null +++ b/plane-src/apps/proxy/Caddyfile.aio.ce @@ -0,0 +1,46 @@ +(plane_proxy) { + request_body { + max_size {$FILE_SIZE_LIMIT} + } + + handle /spaces/* { + reverse_proxy localhost:3002 + } + + handle /live/* { + reverse_proxy localhost:3005 + } + handle /api/* { + reverse_proxy localhost:3004 + } + + handle /auth/* { + reverse_proxy localhost:3004 + } + + handle_path /god-mode* { + root * /app/admin + try_files {path} {path}/ /index.html + file_server + } + handle_path /* { + root * /app/web + try_files {path} {path}/ /index.html + file_server + } +} + +{ + {$CERT_EMAIL} + acme_ca {$CERT_ACME_CA:https://acme-v02.api.letsencrypt.org/directory} + {$CERT_ACME_DNS} + servers { + max_header_size 25MB + client_ip_headers X-Forwarded-For X-Real-IP + trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0} + } +} + +{$SITE_ADDRESS} { + import plane_proxy +} \ No newline at end of file diff --git a/plane-src/apps/proxy/Caddyfile.ce b/plane-src/apps/proxy/Caddyfile.ce new file mode 100644 index 0000000..14559f2 --- /dev/null +++ b/plane-src/apps/proxy/Caddyfile.ce @@ -0,0 +1,39 @@ +(plane_proxy) { + request_body { + max_size {$FILE_SIZE_LIMIT} + } + + redir /spaces /spaces/ permanent + reverse_proxy /spaces/* space:3000 + + redir /god-mode /god-mode/ permanent + reverse_proxy /god-mode/* admin:3000 + + reverse_proxy /live/* live:3000 + + reverse_proxy /api/* api:8000 + + reverse_proxy /auth/* api:8000 + + reverse_proxy /static/* api:8000 + + reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000 + reverse_proxy /{$BUCKET_NAME} plane-minio:9000 + + reverse_proxy /* web:3000 +} + +{ + {$CERT_EMAIL} + acme_ca {$CERT_ACME_CA:https://acme-v02.api.letsencrypt.org/directory} + {$CERT_ACME_DNS} + servers { + max_header_size 25MB + client_ip_headers X-Forwarded-For X-Real-IP + trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0} + } +} + +{$SITE_ADDRESS} { + import plane_proxy +} \ No newline at end of file diff --git a/plane-src/apps/proxy/Dockerfile.ce b/plane-src/apps/proxy/Dockerfile.ce new file mode 100644 index 0000000..2c0f3ea --- /dev/null +++ b/plane-src/apps/proxy/Dockerfile.ce @@ -0,0 +1,14 @@ +FROM caddy:2.10.0-builder-alpine AS caddy-builder + +RUN xcaddy build \ + --with github.com/caddy-dns/cloudflare@v0.2.1 \ + --with github.com/caddy-dns/digitalocean@04bde2867106aa1b44c2f9da41a285fa02e629c5 \ + --with github.com/mholt/caddy-l4@4d3c80e89c5f80438a3e048a410d5543ff5fb9f4 + +FROM caddy:2.10.0-alpine + +RUN apk add --no-cache nss-tools bash curl + +COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy + +COPY Caddyfile.ce /etc/caddy/Caddyfile \ No newline at end of file diff --git a/plane-src/apps/space/.env.example b/plane-src/apps/space/.env.example new file mode 100644 index 0000000..59663aa --- /dev/null +++ b/plane-src/apps/space/.env.example @@ -0,0 +1,12 @@ +VITE_API_BASE_URL="http://localhost:8000" + +VITE_WEB_BASE_URL="http://localhost:3000" + +VITE_ADMIN_BASE_URL="http://localhost:3001" +VITE_ADMIN_BASE_PATH="/god-mode" + +VITE_SPACE_BASE_URL="http://localhost:3002" +VITE_SPACE_BASE_PATH="/spaces" + +VITE_LIVE_BASE_URL="http://localhost:3100" +VITE_LIVE_BASE_PATH="/live" diff --git a/plane-src/apps/space/.gitignore b/plane-src/apps/space/.gitignore new file mode 100644 index 0000000..5449b29 --- /dev/null +++ b/plane-src/apps/space/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# react-router +/.react-router/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# env +.env + +# Sentry Config File +.env.sentry-build-plugin diff --git a/plane-src/apps/space/.prettierignore b/plane-src/apps/space/.prettierignore new file mode 100644 index 0000000..b0b8bc6 --- /dev/null +++ b/plane-src/apps/space/.prettierignore @@ -0,0 +1,10 @@ +.next/ +.react-router/ +.turbo/ +.vite/ +build/ +dist/ +node_modules/ +out/ +pnpm-lock.yaml +storybook-static/ diff --git a/plane-src/apps/space/Dockerfile.dev b/plane-src/apps/space/Dockerfile.dev new file mode 100644 index 0000000..20ac137 --- /dev/null +++ b/plane-src/apps/space/Dockerfile.dev @@ -0,0 +1,19 @@ +FROM node:22-alpine + +RUN apk add --no-cache libc6-compat + +# Set working directory +WORKDIR /app + +COPY . . + +RUN corepack enable pnpm && pnpm add -g turbo +RUN pnpm install + +EXPOSE 3002 + +ENV VITE_SPACE_BASE_PATH="/spaces" + +VOLUME [ "/app/node_modules", "/app/apps/space/node_modules"] + +CMD ["pnpm", "dev", "--filter=space"] diff --git a/plane-src/apps/space/Dockerfile.space b/plane-src/apps/space/Dockerfile.space new file mode 100644 index 0000000..60d4a15 --- /dev/null +++ b/plane-src/apps/space/Dockerfile.space @@ -0,0 +1,93 @@ +FROM node:22-alpine AS base + +WORKDIR /app + +ENV TURBO_TELEMETRY_DISABLED=1 +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +ENV CI=1 + +RUN corepack enable pnpm + +# =========================================================================== # + +FROM base AS builder + +RUN pnpm add -g turbo@2.9.4 + +COPY . . + +# Create a pruned workspace for just the space app +RUN turbo prune --scope=space --docker + +# =========================================================================== # + +FROM base AS installer + +# Build in production mode; we still install dev deps explicitly below +ENV NODE_ENV=production + +# Public envs required at build time (pick up via process.env) +ARG VITE_API_BASE_URL="" +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +ARG VITE_API_BASE_PATH="/api" +ENV VITE_API_BASE_PATH=$VITE_API_BASE_PATH + +ARG VITE_ADMIN_BASE_URL="" +ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL +ARG VITE_ADMIN_BASE_PATH="/god-mode" +ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_BASE_PATH + +ARG VITE_SPACE_BASE_URL="" +ENV VITE_SPACE_BASE_URL=$VITE_SPACE_BASE_URL +ARG VITE_SPACE_BASE_PATH="/spaces" +ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH + +ARG VITE_LIVE_BASE_URL="" +ENV VITE_LIVE_BASE_URL=$VITE_LIVE_BASE_URL +ARG VITE_LIVE_BASE_PATH="/live" +ENV VITE_LIVE_BASE_PATH=$VITE_LIVE_BASE_PATH + +ARG VITE_WEB_BASE_URL="" +ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL +ARG VITE_WEB_BASE_PATH="" +ENV VITE_WEB_BASE_PATH=$VITE_WEB_BASE_PATH + +ARG VITE_WEBSITE_URL="https://plane.so" +ENV VITE_WEBSITE_URL=$VITE_WEBSITE_URL + +ARG VITE_SUPPORT_EMAIL="support@plane.so" +ENV VITE_SUPPORT_EMAIL=$VITE_SUPPORT_EMAIL + +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml + +# Copy full directory structure before fetch to ensure all package.json files are available +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +# Fetch dependencies to cache store, then install offline with dev deps +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false + +# Build only the space package +RUN pnpm turbo run build --filter=space + +# =========================================================================== # + +FROM base AS runner + +COPY --from=installer /app/apps/space/build ./apps/space/build +COPY --from=installer /app/apps/space/node_modules ./apps/space/node_modules +COPY --from=installer /app/node_modules ./node_modules + +WORKDIR /app/apps/space + +EXPOSE 3000 + +RUN apk add --no-cache curl +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:3000/spaces/ >/dev/null || exit 1 + +CMD ["npx", "react-router-serve", "./build/server/index.js"] diff --git a/plane-src/apps/space/README.md b/plane-src/apps/space/README.md new file mode 100644 index 0000000..fc22981 --- /dev/null +++ b/plane-src/apps/space/README.md @@ -0,0 +1,10 @@ +

+ +

+ + Plane Logo + +

+ +

Plane Space

+

Open-source, self-hosted project planning tool

diff --git a/plane-src/apps/space/additional.d.ts b/plane-src/apps/space/additional.d.ts new file mode 100644 index 0000000..f400344 --- /dev/null +++ b/plane-src/apps/space/additional.d.ts @@ -0,0 +1,2 @@ +// additional.d.ts +/// diff --git a/plane-src/apps/space/app/[workspaceSlug]/[projectId]/page.tsx b/plane-src/apps/space/app/[workspaceSlug]/[projectId]/page.tsx new file mode 100644 index 0000000..b14ec17 --- /dev/null +++ b/plane-src/apps/space/app/[workspaceSlug]/[projectId]/page.tsx @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { redirect } from "react-router"; +// plane imports +import { SitesProjectPublishService } from "@plane/services"; +import type { TProjectPublishSettings } from "@plane/types"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import type { Route } from "./+types/page"; + +const publishService = new SitesProjectPublishService(); + +export const clientLoader = async ({ params, request }: Route.ClientLoaderArgs) => { + const { workspaceSlug, projectId } = params; + + // Validate required params + if (!workspaceSlug || !projectId) { + throw redirect("/404"); + } + + // Extract query params from the request URL + const url = new URL(request.url); + const board = url.searchParams.get("board"); + const peekId = url.searchParams.get("peekId"); + + let response: TProjectPublishSettings | undefined = undefined; + + try { + response = await publishService.retrieveSettingsByProjectId(workspaceSlug, projectId); + } catch { + throw redirect("/404"); + } + + if (response?.entity_name === "project") { + let redirectUrl = `/issues/${response?.anchor}`; + const urlParams = new URLSearchParams(); + if (board) urlParams.append("board", String(board)); + if (peekId) urlParams.append("peekId", String(peekId)); + if (urlParams.toString()) redirectUrl += `?${urlParams.toString()}`; + + throw redirect(redirectUrl); + } else { + throw redirect("/404"); + } +}; + +export default function IssuesPage() { + return ( +
+ +
+ ); +} diff --git a/plane-src/apps/space/app/assets/404.svg b/plane-src/apps/space/app/assets/404.svg new file mode 100644 index 0000000..4c29841 --- /dev/null +++ b/plane-src/apps/space/app/assets/404.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/space/app/assets/auth/background-pattern-dark.svg b/plane-src/apps/space/app/assets/auth/background-pattern-dark.svg new file mode 100644 index 0000000..c258cba --- /dev/null +++ b/plane-src/apps/space/app/assets/auth/background-pattern-dark.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/space/app/assets/auth/background-pattern.svg b/plane-src/apps/space/app/assets/auth/background-pattern.svg new file mode 100644 index 0000000..5fcbeec --- /dev/null +++ b/plane-src/apps/space/app/assets/auth/background-pattern.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/space/app/assets/favicon/apple-touch-icon.png b/plane-src/apps/space/app/assets/favicon/apple-touch-icon.png new file mode 100644 index 0000000..a631267 Binary files /dev/null and b/plane-src/apps/space/app/assets/favicon/apple-touch-icon.png differ diff --git a/plane-src/apps/space/app/assets/favicon/favicon-16x16.png b/plane-src/apps/space/app/assets/favicon/favicon-16x16.png new file mode 100644 index 0000000..af59ef0 Binary files /dev/null and b/plane-src/apps/space/app/assets/favicon/favicon-16x16.png differ diff --git a/plane-src/apps/space/app/assets/favicon/favicon-32x32.png b/plane-src/apps/space/app/assets/favicon/favicon-32x32.png new file mode 100644 index 0000000..16a1271 Binary files /dev/null and b/plane-src/apps/space/app/assets/favicon/favicon-32x32.png differ diff --git a/plane-src/apps/space/app/assets/favicon/favicon.ico b/plane-src/apps/space/app/assets/favicon/favicon.ico new file mode 100644 index 0000000..613b1a3 Binary files /dev/null and b/plane-src/apps/space/app/assets/favicon/favicon.ico differ diff --git a/plane-src/apps/space/app/assets/favicon/site.webmanifest b/plane-src/apps/space/app/assets/favicon/site.webmanifest new file mode 100644 index 0000000..1d41057 --- /dev/null +++ b/plane-src/apps/space/app/assets/favicon/site.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/plane-src/apps/space/app/assets/images/logo-spinner-dark.gif b/plane-src/apps/space/app/assets/images/logo-spinner-dark.gif new file mode 100644 index 0000000..8bd0832 Binary files /dev/null and b/plane-src/apps/space/app/assets/images/logo-spinner-dark.gif differ diff --git a/plane-src/apps/space/app/assets/images/logo-spinner-light.gif b/plane-src/apps/space/app/assets/images/logo-spinner-light.gif new file mode 100644 index 0000000..8b57103 Binary files /dev/null and b/plane-src/apps/space/app/assets/images/logo-spinner-light.gif differ diff --git a/plane-src/apps/space/app/assets/instance/instance-failure-dark.svg b/plane-src/apps/space/app/assets/instance/instance-failure-dark.svg new file mode 100644 index 0000000..58d6917 --- /dev/null +++ b/plane-src/apps/space/app/assets/instance/instance-failure-dark.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/space/app/assets/instance/instance-failure.svg b/plane-src/apps/space/app/assets/instance/instance-failure.svg new file mode 100644 index 0000000..a598622 --- /dev/null +++ b/plane-src/apps/space/app/assets/instance/instance-failure.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/space/app/assets/instance/intake-sent-dark.png b/plane-src/apps/space/app/assets/instance/intake-sent-dark.png new file mode 100644 index 0000000..70a6273 Binary files /dev/null and b/plane-src/apps/space/app/assets/instance/intake-sent-dark.png differ diff --git a/plane-src/apps/space/app/assets/instance/intake-sent-light.png b/plane-src/apps/space/app/assets/instance/intake-sent-light.png new file mode 100644 index 0000000..9425c07 Binary files /dev/null and b/plane-src/apps/space/app/assets/instance/intake-sent-light.png differ diff --git a/plane-src/apps/space/app/assets/instance/plane-instance-not-ready.webp b/plane-src/apps/space/app/assets/instance/plane-instance-not-ready.webp new file mode 100644 index 0000000..a0efca5 Binary files /dev/null and b/plane-src/apps/space/app/assets/instance/plane-instance-not-ready.webp differ diff --git a/plane-src/apps/space/app/assets/instance/plane-takeoff.png b/plane-src/apps/space/app/assets/instance/plane-takeoff.png new file mode 100644 index 0000000..417ff82 Binary files /dev/null and b/plane-src/apps/space/app/assets/instance/plane-takeoff.png differ diff --git a/plane-src/apps/space/app/assets/logos/gitea-logo.svg b/plane-src/apps/space/app/assets/logos/gitea-logo.svg new file mode 100644 index 0000000..4329134 --- /dev/null +++ b/plane-src/apps/space/app/assets/logos/gitea-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plane-src/apps/space/app/assets/logos/github-black.png b/plane-src/apps/space/app/assets/logos/github-black.png new file mode 100644 index 0000000..7a7a824 Binary files /dev/null and b/plane-src/apps/space/app/assets/logos/github-black.png differ diff --git a/plane-src/apps/space/app/assets/logos/github-dark.svg b/plane-src/apps/space/app/assets/logos/github-dark.svg new file mode 100644 index 0000000..a0cb35c --- /dev/null +++ b/plane-src/apps/space/app/assets/logos/github-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/plane-src/apps/space/app/assets/logos/github-square.svg b/plane-src/apps/space/app/assets/logos/github-square.svg new file mode 100644 index 0000000..a7836db --- /dev/null +++ b/plane-src/apps/space/app/assets/logos/github-square.svg @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/plane-src/apps/space/app/assets/logos/github-white.svg b/plane-src/apps/space/app/assets/logos/github-white.svg new file mode 100644 index 0000000..90fe34d --- /dev/null +++ b/plane-src/apps/space/app/assets/logos/github-white.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/plane-src/apps/space/app/assets/logos/gitlab-logo.svg b/plane-src/apps/space/app/assets/logos/gitlab-logo.svg new file mode 100644 index 0000000..dab4d8b --- /dev/null +++ b/plane-src/apps/space/app/assets/logos/gitlab-logo.svg @@ -0,0 +1 @@ + diff --git a/plane-src/apps/space/app/assets/logos/google-logo.svg b/plane-src/apps/space/app/assets/logos/google-logo.svg new file mode 100644 index 0000000..088288f --- /dev/null +++ b/plane-src/apps/space/app/assets/logos/google-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plane-src/apps/space/app/assets/plane-logo.svg b/plane-src/apps/space/app/assets/plane-logo.svg new file mode 100644 index 0000000..1117941 --- /dev/null +++ b/plane-src/apps/space/app/assets/plane-logo.svg @@ -0,0 +1,94 @@ + + + + diff --git a/plane-src/apps/space/app/assets/plane-logos/black-horizontal-with-blue-logo.png b/plane-src/apps/space/app/assets/plane-logos/black-horizontal-with-blue-logo.png new file mode 100644 index 0000000..c14505a Binary files /dev/null and b/plane-src/apps/space/app/assets/plane-logos/black-horizontal-with-blue-logo.png differ diff --git a/plane-src/apps/space/app/assets/plane-logos/blue-without-text-new.png b/plane-src/apps/space/app/assets/plane-logos/blue-without-text-new.png new file mode 100644 index 0000000..ea94aec Binary files /dev/null and b/plane-src/apps/space/app/assets/plane-logos/blue-without-text-new.png differ diff --git a/plane-src/apps/space/app/assets/plane-logos/blue-without-text.png b/plane-src/apps/space/app/assets/plane-logos/blue-without-text.png new file mode 100644 index 0000000..ea94aec Binary files /dev/null and b/plane-src/apps/space/app/assets/plane-logos/blue-without-text.png differ diff --git a/plane-src/apps/space/app/assets/plane-logos/white-horizontal-with-blue-logo.png b/plane-src/apps/space/app/assets/plane-logos/white-horizontal-with-blue-logo.png new file mode 100644 index 0000000..97560fb Binary files /dev/null and b/plane-src/apps/space/app/assets/plane-logos/white-horizontal-with-blue-logo.png differ diff --git a/plane-src/apps/space/app/assets/plane-logos/white-horizontal.svg b/plane-src/apps/space/app/assets/plane-logos/white-horizontal.svg new file mode 100644 index 0000000..13e2dbb --- /dev/null +++ b/plane-src/apps/space/app/assets/plane-logos/white-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/space/app/assets/project-not-published.svg b/plane-src/apps/space/app/assets/project-not-published.svg new file mode 100644 index 0000000..db4b404 --- /dev/null +++ b/plane-src/apps/space/app/assets/project-not-published.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plane-src/apps/space/app/assets/robots.txt b/plane-src/apps/space/app/assets/robots.txt new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/plane-src/apps/space/app/assets/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/plane-src/apps/space/app/assets/something-went-wrong.svg b/plane-src/apps/space/app/assets/something-went-wrong.svg new file mode 100644 index 0000000..bd51f7f --- /dev/null +++ b/plane-src/apps/space/app/assets/something-went-wrong.svg @@ -0,0 +1,3 @@ + + + diff --git a/plane-src/apps/space/app/assets/user-logged-in.svg b/plane-src/apps/space/app/assets/user-logged-in.svg new file mode 100644 index 0000000..e20b49e --- /dev/null +++ b/plane-src/apps/space/app/assets/user-logged-in.svg @@ -0,0 +1,3 @@ + + + diff --git a/plane-src/apps/space/app/compat/next/helper.ts b/plane-src/apps/space/app/compat/next/helper.ts new file mode 100644 index 0000000..c4edf3d --- /dev/null +++ b/plane-src/apps/space/app/compat/next/helper.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +/** + * Ensures that a URL has a trailing slash while preserving query parameters and fragments + * @param url - The URL to process + * @returns The URL with a trailing slash added to the pathname (if not already present) + */ +export function ensureTrailingSlash(url: string): string { + try { + const fallbackBaseUrl = + typeof window !== "undefined" && window.location.origin ? window.location.origin : "http://dummy.com"; + // Handle relative URLs by creating a URL object with a fallback base URL + const urlObj = new URL(url, fallbackBaseUrl); + + // Don't modify root path + if (urlObj.pathname === "/") { + return url; + } + + // Add trailing slash if it doesn't exist + if (!urlObj.pathname.endsWith("/")) { + urlObj.pathname += "/"; + } + + // For relative URLs, return just the path + search + hash + if (url.startsWith("/")) { + return urlObj.pathname + urlObj.search + urlObj.hash; + } + + // For absolute URLs, return the full URL + return urlObj.toString(); + } catch (error) { + // If URL parsing fails, return the original URL + console.warn("Failed to parse URL for trailing slash enforcement:", url, error); + return url; + } +} diff --git a/plane-src/apps/space/app/compat/next/navigation.ts b/plane-src/apps/space/app/compat/next/navigation.ts new file mode 100644 index 0000000..0aa9254 --- /dev/null +++ b/plane-src/apps/space/app/compat/next/navigation.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useMemo } from "react"; +import { useLocation, useNavigate, useParams as useParamsRR, useSearchParams as useSearchParamsRR } from "react-router"; +import { ensureTrailingSlash } from "./helper"; + +export function useRouter() { + const navigate = useNavigate(); + return useMemo( + () => ({ + push: (to: string) => navigate(ensureTrailingSlash(to)), + replace: (to: string) => navigate(ensureTrailingSlash(to), { replace: true }), + back: () => navigate(-1), + forward: () => navigate(1), + refresh: () => { + location.reload(); + }, + prefetch: async (_to: string) => { + // no-op in this shim + }, + }), + [navigate] + ); +} + +export function usePathname(): string { + const { pathname } = useLocation(); + return pathname; +} + +export function useSearchParams(): URLSearchParams { + const [searchParams] = useSearchParamsRR(); + return searchParams; +} + +export function useParams() { + return useParamsRR(); +} diff --git a/plane-src/apps/space/app/entry.client.tsx b/plane-src/apps/space/app/entry.client.tsx new file mode 100644 index 0000000..9c665ed --- /dev/null +++ b/plane-src/apps/space/app/entry.client.tsx @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { HydratedRouter } from "react-router/dom"; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/plane-src/apps/space/app/error.tsx b/plane-src/apps/space/app/error.tsx new file mode 100644 index 0000000..87aa8dc --- /dev/null +++ b/plane-src/apps/space/app/error.tsx @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// ui +import { Button } from "@plane/propel/button"; + +function ErrorPage() { + const handleRetry = () => { + window.location.reload(); + }; + + return ( +
+
+
+

Yikes! That doesn{"'"}t look good.

+

+ That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more + details, please write to{" "} + + support@plane.so + {" "} + or on our{" "} + + Forum + + . +

+
+
+ + {/* */} +
+
+
+ ); +} + +export default ErrorPage; diff --git a/plane-src/apps/space/app/issues/[anchor]/layout.tsx b/plane-src/apps/space/app/issues/[anchor]/layout.tsx new file mode 100644 index 0000000..6dbc2e7 --- /dev/null +++ b/plane-src/apps/space/app/issues/[anchor]/layout.tsx @@ -0,0 +1,148 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { Outlet } from "react-router"; +import type { ShouldRevalidateFunctionArgs } from "react-router"; +import useSWR from "swr"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PoweredBy } from "@/components/common/powered-by"; +import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error"; +import { IssuesNavbarRoot } from "@/components/issues/navbar"; +// hooks +import { PageNotFound } from "@/components/ui/not-found"; +import { usePublish, usePublishList } from "@/hooks/store/publish"; +import { useIssueFilter } from "@/hooks/store/use-issue-filter"; +import type { Route } from "./+types/layout"; + +const DEFAULT_TITLE = "Plane"; +const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities."; + +interface IssueMetadata { + name?: string; + description?: string; + cover_image?: string; +} + +// Loader function runs on the server and fetches metadata +export async function loader({ params }: Route.LoaderArgs) { + const { anchor } = params; + + // Validate anchor before using in request (only allow alphanumeric, -, _) + const ANCHOR_REGEX = /^[a-zA-Z0-9_-]+$/; + if (!ANCHOR_REGEX.test(anchor)) { + return { metadata: null }; + } + + try { + const response = await fetch(`${process.env.VITE_API_BASE_URL}/api/public/anchor/${anchor}/meta/`); + + if (!response.ok) { + return { metadata: null }; + } + + const metadata: IssueMetadata = await response.json(); + return { metadata }; + } catch (error) { + console.error("Error fetching issue metadata:", error); + return { metadata: null }; + } +} + +// Meta function uses the loader data to generate metadata +export function meta({ loaderData }: Route.MetaArgs) { + const metadata = loaderData?.metadata; + + const title = metadata?.name || DEFAULT_TITLE; + const description = metadata?.description || DEFAULT_DESCRIPTION; + const coverImage = metadata?.cover_image; + + const metaTags = [ + { title }, + { name: "description", content: description }, + // OpenGraph metadata + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:type", content: "website" }, + // Twitter metadata + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + ]; + + // Add images if cover image exists + if (coverImage) { + metaTags.push( + { property: "og:image", content: coverImage }, + { property: "og:image:width", content: "800" }, + { property: "og:image:height", content: "600" }, + { property: "og:image:alt", content: title }, + { name: "twitter:image", content: coverImage } + ); + } + + return metaTags; +} + +// Prevent loader from re-running on anchor param changes +export function shouldRevalidate({ currentParams, nextParams }: ShouldRevalidateFunctionArgs) { + return currentParams.anchor !== nextParams.anchor; +} + +function IssuesLayout(props: Route.ComponentProps) { + const { anchor } = props.params; + // store hooks + const { fetchPublishSettings } = usePublishList(); + const publishSettings = usePublish(anchor); + const { updateLayoutOptions } = useIssueFilter(); + // fetch publish settings + const { error } = useSWR( + anchor ? `PUBLISH_SETTINGS_${anchor}` : null, + anchor + ? async () => { + const response = await fetchPublishSettings(anchor); + if (response.view_props) { + updateLayoutOptions({ + list: !!response.view_props.list, + kanban: !!response.view_props.kanban, + calendar: !!response.view_props.calendar, + gantt: !!response.view_props.gantt, + spreadsheet: !!response.view_props.spreadsheet, + }); + } + } + : null + ); + + if (!publishSettings && !error) { + return ( +
+ +
+ ); + } + + if (error?.status === 404) return ; + + if (error) return ; + + return ( + <> +
+
+ +
+
+ +
+
+ + + ); +} + +export default observer(IssuesLayout); diff --git a/plane-src/apps/space/app/issues/[anchor]/page.tsx b/plane-src/apps/space/app/issues/[anchor]/page.tsx new file mode 100644 index 0000000..cd19bda --- /dev/null +++ b/plane-src/apps/space/app/issues/[anchor]/page.tsx @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams, useSearchParams } from "next/navigation"; +import useSWR from "swr"; +// components +import { IssuesLayoutsRoot } from "@/components/issues/issue-layouts"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useLabel } from "@/hooks/store/use-label"; +import { useStates } from "@/hooks/store/use-state"; + +const IssuesPage = observer(function IssuesPage() { + // params + const params = useParams<{ anchor: string }>(); + const { anchor } = params; + const searchParams = useSearchParams(); + const peekId = searchParams.get("peekId") || undefined; + // store + const { fetchStates } = useStates(); + const { fetchLabels } = useLabel(); + + useSWR(anchor ? `PUBLIC_STATES_${anchor}` : null, anchor ? () => fetchStates(anchor) : null); + useSWR(anchor ? `PUBLIC_LABELS_${anchor}` : null, anchor ? () => fetchLabels(anchor) : null); + + const publishSettings = usePublish(anchor); + + if (!publishSettings) return null; + + return ; +}); + +export default IssuesPage; diff --git a/plane-src/apps/space/app/not-found.tsx b/plane-src/apps/space/app/not-found.tsx new file mode 100644 index 0000000..e137fdd --- /dev/null +++ b/plane-src/apps/space/app/not-found.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// assets +import SomethingWentWrongImage from "@/app/assets/something-went-wrong.svg?url"; + +function NotFound() { + return ( +
+
+
+
+ Something went wrong +
+
+

That didn{"'"}t work

+

+ Check the URL you are entering in the browser{"'"}s address bar and try again. +

+
+
+ ); +} + +export default NotFound; diff --git a/plane-src/apps/space/app/page.tsx b/plane-src/apps/space/app/page.tsx new file mode 100644 index 0000000..02a9a56 --- /dev/null +++ b/plane-src/apps/space/app/page.tsx @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams, useRouter } from "next/navigation"; +// plane imports +import { isValidNextPath } from "@plane/utils"; +// components +import { UserLoggedIn } from "@/components/account/user-logged-in"; +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { AuthView } from "@/components/views"; +// hooks +import { useUser } from "@/hooks/store/use-user"; +import type { Route } from "./+types/page"; + +export const headers: Route.HeadersFunction = () => ({ + "X-Frame-Options": "SAMEORIGIN", +}); + +const HomePage = observer(function HomePage() { + const { data: currentUser, isAuthenticated, isInitializing } = useUser(); + const searchParams = useSearchParams(); + const router = useRouter(); + const nextPath = searchParams.get("next_path"); + + useEffect(() => { + if (currentUser && isAuthenticated && nextPath && isValidNextPath(nextPath)) { + router.replace(nextPath); + } + }, [currentUser, isAuthenticated, nextPath, router]); + + if (isInitializing) + return ( +
+ +
+ ); + + if (currentUser && isAuthenticated) { + if (nextPath && isValidNextPath(nextPath)) { + return ( +
+ +
+ ); + } + return ; + } + + return ; +}); + +export default HomePage; diff --git a/plane-src/apps/space/app/providers.tsx b/plane-src/apps/space/app/providers.tsx new file mode 100644 index 0000000..463770e --- /dev/null +++ b/plane-src/apps/space/app/providers.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { ThemeProvider } from "next-themes"; +// components +import { TranslationProvider } from "@plane/i18n"; +import { AppProgressBar } from "@/lib/b-progress"; +import { InstanceProvider } from "@/lib/instance-provider"; +import { StoreProvider } from "@/lib/store-provider"; +import { ToastProvider } from "@/lib/toast-provider"; + +export function AppProviders({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + + + ); +} diff --git a/plane-src/apps/space/app/root.tsx b/plane-src/apps/space/app/root.tsx new file mode 100644 index 0000000..fe504b1 --- /dev/null +++ b/plane-src/apps/space/app/root.tsx @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Links, Meta, Outlet, Scripts } from "react-router"; +// assets +import appleTouchIcon from "@/app/assets/favicon/apple-touch-icon.png?url"; +import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url"; +import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url"; +import faviconIco from "@/app/assets/favicon/favicon.ico?url"; +import siteWebmanifest from "@/app/assets/favicon/site.webmanifest?url"; +import { LogoSpinner } from "@/components/common/logo-spinner"; +import globalStyles from "@/styles/globals.css?url"; +// types +import type { Route } from "./+types/root"; +// local imports +import ErrorPage from "./error"; +import { AppProviders } from "./providers"; +// fonts +import "@fontsource-variable/inter"; +import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url"; +import "@fontsource/material-symbols-rounded"; +import "@fontsource/ibm-plex-mono"; + +const APP_TITLE = "Plane Publish | Make your Plane boards public with one-click"; +const APP_DESCRIPTION = "Plane Publish is a customer feedback management tool built on top of plane.so"; + +export const links: Route.LinksFunction = () => [ + { rel: "apple-touch-icon", sizes: "180x180", href: appleTouchIcon }, + { rel: "icon", type: "image/png", sizes: "32x32", href: favicon32 }, + { rel: "icon", type: "image/png", sizes: "16x16", href: favicon16 }, + { rel: "shortcut icon", href: faviconIco }, + { rel: "manifest", href: siteWebmanifest }, + { rel: "stylesheet", href: globalStyles }, + { + rel: "preload", + href: interVariableWoff2, + as: "font", + type: "font/woff2", + crossOrigin: "anonymous", + }, +]; + +export const headers: Route.HeadersFunction = () => ({ + "Referrer-Policy": "origin-when-cross-origin", + "X-Content-Type-Options": "nosniff", + "X-DNS-Prefetch-Control": "on", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", +}); + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + +
+ {children} + + + + ); +} + +export const meta: Route.MetaFunction = () => [ + { title: APP_TITLE }, + { name: "description", content: APP_DESCRIPTION }, + { property: "og:title", content: APP_TITLE }, + { property: "og:description", content: APP_DESCRIPTION }, + { property: "og:url", content: "https://sites.plane.so/" }, + { + name: "keywords", + content: + "software development, customer feedback, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", + }, + { name: "twitter:site", content: "@planepowers" }, +]; + +export default function Root() { + return ; +} + +export function HydrateFallback() { + return ( +
+ +
+ ); +} + +export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) { + return ; +} diff --git a/plane-src/apps/space/app/routes.ts b/plane-src/apps/space/app/routes.ts new file mode 100644 index 0000000..1a94ca0 --- /dev/null +++ b/plane-src/apps/space/app/routes.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { RouteConfig } from "@react-router/dev/routes"; +import { index, layout, route } from "@react-router/dev/routes"; + +export default [ + index("./page.tsx"), + route(":workspaceSlug/:projectId", "./[workspaceSlug]/[projectId]/page.tsx"), + layout("./issues/[anchor]/layout.tsx", [route("issues/:anchor", "./issues/[anchor]/page.tsx")]), + // Catch-all route for 404 handling + route("*", "./not-found.tsx"), +] satisfies RouteConfig; diff --git a/plane-src/apps/space/app/types/next-link.d.ts b/plane-src/apps/space/app/types/next-link.d.ts new file mode 100644 index 0000000..e39cc52 --- /dev/null +++ b/plane-src/apps/space/app/types/next-link.d.ts @@ -0,0 +1,14 @@ +declare module "next/link" { + import type { FC, ComponentProps } from "react"; + + type NextLinkProps = ComponentProps<"a"> & { + href: string; + replace?: boolean; + prefetch?: boolean; + scroll?: boolean; + shallow?: boolean; + }; + + const Link: FC; + export default Link; +} diff --git a/plane-src/apps/space/app/types/next-navigation.d.ts b/plane-src/apps/space/app/types/next-navigation.d.ts new file mode 100644 index 0000000..67a80c4 --- /dev/null +++ b/plane-src/apps/space/app/types/next-navigation.d.ts @@ -0,0 +1,14 @@ +declare module "next/navigation" { + export function useRouter(): { + push: (url: string) => void; + replace: (url: string) => void; + back: () => void; + forward: () => void; + refresh: () => void; + prefetch: (url: string) => Promise; + }; + + export function usePathname(): string; + export function useSearchParams(): URLSearchParams; + export function useParams>(): T; +} diff --git a/plane-src/apps/space/app/types/react-router-virtual.d.ts b/plane-src/apps/space/app/types/react-router-virtual.d.ts new file mode 100644 index 0000000..68caf20 --- /dev/null +++ b/plane-src/apps/space/app/types/react-router-virtual.d.ts @@ -0,0 +1,5 @@ +declare module "virtual:react-router/server-build" { + import type { ServerBuild } from "react-router"; + const serverBuild: ServerBuild; + export default serverBuild; +} diff --git a/plane-src/apps/space/components/account/auth-forms/auth-banner.tsx b/plane-src/apps/space/components/account/auth-forms/auth-banner.tsx new file mode 100644 index 0000000..4a9c517 --- /dev/null +++ b/plane-src/apps/space/components/account/auth-forms/auth-banner.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Info } from "lucide-react"; +import { CloseIcon } from "@plane/propel/icons"; +// helpers +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; + +type TAuthBanner = { + bannerData: TAuthErrorInfo | undefined; + handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void; +}; + +export function AuthBanner(props: TAuthBanner) { + const { bannerData, handleBannerData } = props; + + if (!bannerData) return <>; + return ( +
+
+ +
+
{bannerData?.message}
+
handleBannerData && handleBannerData(undefined)} + > + +
+
+ ); +} diff --git a/plane-src/apps/space/components/account/auth-forms/auth-header.tsx b/plane-src/apps/space/components/account/auth-forms/auth-header.tsx new file mode 100644 index 0000000..a2063fe --- /dev/null +++ b/plane-src/apps/space/components/account/auth-forms/auth-header.tsx @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// helpers +import { EAuthModes } from "@/types/auth"; + +type TAuthHeader = { + authMode: EAuthModes; +}; + +type TAuthHeaderContent = { + header: string; + subHeader: string; +}; + +type TAuthHeaderDetails = { + [mode in EAuthModes]: TAuthHeaderContent; +}; + +const Titles: TAuthHeaderDetails = { + [EAuthModes.SIGN_IN]: { + header: "Sign in to upvote or comment", + subHeader: "Contribute in nudging the features you want to get built.", + }, + [EAuthModes.SIGN_UP]: { + header: "View, comment, and do more", + subHeader: "Sign up or log in to work with Plane work items and Pages.", + }, +}; + +export function AuthHeader(props: TAuthHeader) { + const { authMode } = props; + + const getHeaderSubHeader = (mode: EAuthModes | null): TAuthHeaderContent => { + if (mode) { + return Titles[mode]; + } + + return { + header: "Comment or react to work items", + subHeader: "Use plane to add your valuable inputs to features.", + }; + }; + + const { header, subHeader } = getHeaderSubHeader(authMode); + + return ( + <> +
+ {header} + {subHeader} +
+ + ); +} diff --git a/plane-src/apps/space/components/account/auth-forms/auth-root.tsx b/plane-src/apps/space/components/account/auth-forms/auth-root.tsx new file mode 100644 index 0000000..56d3d9b --- /dev/null +++ b/plane-src/apps/space/components/account/auth-forms/auth-root.tsx @@ -0,0 +1,196 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// plane imports +import { SitesAuthService } from "@plane/services"; +import type { IEmailCheckData } from "@plane/types"; +import { OAuthOptions } from "@plane/ui"; +// helpers +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { EErrorAlertType, authErrorHandler, EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; +// hooks +import { useOAuthConfig } from "@/hooks/oauth"; +import { useInstance } from "@/hooks/store/use-instance"; +// types +import { EAuthModes, EAuthSteps } from "@/types/auth"; +// local imports +import { TermsAndConditions } from "../terms-and-conditions"; +import { AuthBanner } from "./auth-banner"; +import { AuthHeader } from "./auth-header"; +import { AuthEmailForm } from "./email"; +import { AuthPasswordForm } from "./password"; +import { AuthUniqueCodeForm } from "./unique-code"; + +const authService = new SitesAuthService(); + +export const AuthRoot = observer(function AuthRoot() { + // router params + const searchParams = useSearchParams(); + const emailParam = searchParams.get("email") || undefined; + const error_code = searchParams.get("error_code") || undefined; + const nextPath = searchParams.get("next_path") || undefined; + // states + const [authMode, setAuthMode] = useState(EAuthModes.SIGN_UP); + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); + const [errorInfo, setErrorInfo] = useState(undefined); + const [isPasswordAutoset, setIsPasswordAutoset] = useState(true); + // hooks + const { config } = useInstance(); + + useEffect(() => { + if (error_code) { + const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes); + if (errorhandler) { + if (errorhandler.code === EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN) { + setAuthMode(EAuthModes.SIGN_IN); + setAuthStep(EAuthSteps.PASSWORD); + } + if (errorhandler.code === EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP) { + setAuthMode(EAuthModes.SIGN_UP); + setAuthStep(EAuthSteps.PASSWORD); + } + if (errorhandler.code === EAuthenticationErrorCodes.PASSWORD_TOO_WEAK) { + setAuthMode(EAuthModes.SIGN_UP); + setAuthStep(EAuthSteps.PASSWORD); + } + if ( + [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN, + ].includes(errorhandler.code) + ) { + setAuthMode(EAuthModes.SIGN_IN); + setAuthStep(EAuthSteps.UNIQUE_CODE); + } + if ( + [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP, + ].includes(errorhandler.code) + ) { + setAuthMode(EAuthModes.SIGN_UP); + setAuthStep(EAuthSteps.UNIQUE_CODE); + } + setErrorInfo(errorhandler); + } + } + }, [error_code]); + + const isSMTPConfigured = config?.is_smtp_configured || false; + const isMagicLoginEnabled = config?.is_magic_login_enabled || false; + const isEmailPasswordEnabled = config?.is_email_password_enabled || false; + const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in"; + const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText); + + // submit handler- email verification + const handleEmailVerification = async (data: IEmailCheckData) => { + setEmail(data.email); + + await authService + .emailCheck(data) + .then(async (response) => { + let currentAuthMode: EAuthModes = response.existing ? EAuthModes.SIGN_IN : EAuthModes.SIGN_UP; + if (response.existing) { + currentAuthMode = EAuthModes.SIGN_IN; + setAuthMode(() => EAuthModes.SIGN_IN); + } else { + currentAuthMode = EAuthModes.SIGN_UP; + setAuthMode(() => EAuthModes.SIGN_UP); + } + + if (currentAuthMode === EAuthModes.SIGN_IN) { + if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") { + setAuthStep(EAuthSteps.UNIQUE_CODE); + generateEmailUniqueCode(data.email); + } else if (isEmailPasswordEnabled) { + setIsPasswordAutoset(false); + setAuthStep(EAuthSteps.PASSWORD); + } else { + const errorhandler = authErrorHandler("5005" as EAuthenticationErrorCodes); + setErrorInfo(errorhandler); + } + } else { + if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") { + setAuthStep(EAuthSteps.UNIQUE_CODE); + generateEmailUniqueCode(data.email); + } else if (isEmailPasswordEnabled) { + setAuthStep(EAuthSteps.PASSWORD); + } else { + const errorhandler = authErrorHandler("5006" as EAuthenticationErrorCodes); + setErrorInfo(errorhandler); + } + } + return; + }) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined); + if (errorhandler?.type) setErrorInfo(errorhandler); + }); + }; + + // generating the unique code + const generateEmailUniqueCode = async (email: string): Promise<{ code: string } | undefined> => { + const payload = { email: email }; + return await authService + .generateUniqueCode(payload) + .then(() => ({ code: "" })) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code.toString()); + if (errorhandler?.type) setErrorInfo(errorhandler); + throw error; + }); + }; + + return ( +
+
+ {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(value)} /> + )} + + {isOAuthEnabled && } + + {authStep === EAuthSteps.EMAIL && } + {authStep === EAuthSteps.UNIQUE_CODE && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + generateEmailUniqueCode={generateEmailUniqueCode} + /> + )} + {authStep === EAuthSteps.PASSWORD && ( + { + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + }} + handleAuthStep={(step: EAuthSteps) => { + if (step === EAuthSteps.UNIQUE_CODE) generateEmailUniqueCode(email); + setAuthStep(step); + }} + /> + )} + +
+
+ ); +}); diff --git a/plane-src/apps/space/components/account/auth-forms/email.tsx b/plane-src/apps/space/components/account/auth-forms/email.tsx new file mode 100644 index 0000000..303f903 --- /dev/null +++ b/plane-src/apps/space/components/account/auth-forms/email.tsx @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { FormEvent } from "react"; +import { useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +// icons +import { CircleAlert, XCircle } from "lucide-react"; +// types +import { Button } from "@plane/propel/button"; +import type { IEmailCheckData } from "@plane/types"; +// ui +import { Input, Spinner } from "@plane/ui"; +// helpers +import { cn } from "@plane/utils"; +import { checkEmailValidity } from "@/helpers/string.helper"; + +type TAuthEmailForm = { + defaultEmail: string; + onSubmit: (data: IEmailCheckData) => Promise; +}; + +export const AuthEmailForm = observer(function AuthEmailForm(props: TAuthEmailForm) { + const { onSubmit, defaultEmail } = props; + // states + const [isSubmitting, setIsSubmitting] = useState(false); + const [email, setEmail] = useState(defaultEmail); + + const emailError = useMemo( + () => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined), + [email] + ); + + const handleFormSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + const payload: IEmailCheckData = { + email: email, + }; + await onSubmit(payload); + setIsSubmitting(false); + }; + + const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting; + + const [isFocused, setIsFocused] = useState(true); + const inputRef = useRef(null); + + return ( +
+
+ +
{ + setIsFocused(true); + }} + onBlur={() => { + setIsFocused(false); + }} + > + setEmail(e.target.value)} + placeholder="name@company.com" + className={`h-10 w-full border-0 disable-autofill-style placeholder:text-placeholder autofill:bg-danger-subtle focus:bg-none active:bg-transparent`} + autoComplete="off" + autoFocus + ref={inputRef} + /> + {email.length > 0 && ( + + )} +
+ {emailError?.email && !isFocused && ( +

+ + {emailError.email} +

+ )} +
+ +
+ ); +}); diff --git a/plane-src/apps/space/components/account/auth-forms/index.ts b/plane-src/apps/space/components/account/auth-forms/index.ts new file mode 100644 index 0000000..125f669 --- /dev/null +++ b/plane-src/apps/space/components/account/auth-forms/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./auth-root"; diff --git a/plane-src/apps/space/components/account/auth-forms/password.tsx b/plane-src/apps/space/components/account/auth-forms/password.tsx new file mode 100644 index 0000000..7bf8971 --- /dev/null +++ b/plane-src/apps/space/components/account/auth-forms/password.tsx @@ -0,0 +1,247 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { Eye, EyeOff, XCircle } from "lucide-react"; +// plane imports +import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { AuthService } from "@plane/services"; +import { Input, Spinner, PasswordStrengthIndicator } from "@plane/ui"; +import { getPasswordStrength } from "@plane/utils"; +// types +import { EAuthModes, EAuthSteps } from "@/types/auth"; + +type Props = { + email: string; + isPasswordAutoset: boolean; + isSMTPConfigured: boolean; + mode: EAuthModes; + nextPath: string | undefined; + handleEmailClear: () => void; + handleAuthStep: (step: EAuthSteps) => void; +}; + +type TPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TPasswordFormValues = { + email: "", + password: "", +}; + +const authService = new AuthService(); + +export const AuthPasswordForm = observer(function AuthPasswordForm(props: Props) { + const { email, nextPath, isSMTPConfigured, handleAuthStep, handleEmailClear, mode } = props; + // ref + const formRef = useRef(null); + // states + const [csrfPromise, setCsrfPromise] = useState | undefined>(undefined); + const [passwordFormData, setPasswordFormData] = useState({ ...defaultValues, email }); + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleFormChange = (key: keyof TPasswordFormValues, value: string) => + setPasswordFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfPromise === undefined) { + const promise = authService.requestCSRFToken(); + setCsrfPromise(promise); + } + }, [csrfPromise]); + + const redirectToUniqueCodeSignIn = async () => { + handleAuthStep(EAuthSteps.UNIQUE_CODE); + }; + + const passwordSupport = passwordFormData.password.length > 0 && + mode === EAuthModes.SIGN_UP && + getPasswordStrength(passwordFormData.password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && ( + + ); + + const isButtonDisabled = useMemo( + () => + !isSubmitting && + !!passwordFormData.password && + (mode === EAuthModes.SIGN_UP + ? getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID && + passwordFormData.password === passwordFormData.confirm_password + : true) + ? false + : true, + [isSubmitting, mode, passwordFormData.confirm_password, passwordFormData.password] + ); + + const password = passwordFormData.password ?? ""; + const confirmPassword = passwordFormData.confirm_password ?? ""; + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + const handleCSRFToken = async () => { + if (!formRef || !formRef.current) return; + const token = await csrfPromise; + if (!token?.csrf_token) return; + const csrfElement = formRef.current.querySelector("input[name=csrfmiddlewaretoken]"); + csrfElement?.setAttribute("value", token?.csrf_token); + }; + + return ( +
{ + event.preventDefault(); + await handleCSRFToken(); + if (formRef.current) { + formRef.current.submit(); + } + setIsSubmitting(true); + }} + onError={() => setIsSubmitting(false)} + > + + + +
+ +
+ handleFormChange("email", e.target.value)} + placeholder="name@company.com" + className={`h-10 w-full border-0 disable-autofill-style placeholder:text-placeholder`} + disabled + /> + {passwordFormData.email.length > 0 && ( + + )} +
+
+ +
+ +
+ handleFormChange("password", e.target.value)} + placeholder="Enter password" + className="h-10 w-full border border-subtle !bg-surface-1 pr-12 disable-autofill-style placeholder:text-placeholder" + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoComplete="off" + autoFocus + /> + {showPassword?.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
+ {passwordSupport} +
+ + {mode === EAuthModes.SIGN_UP && ( +
+ +
+ handleFormChange("confirm_password", e.target.value)} + placeholder="Confirm password" + className="h-10 w-full border border-subtle !bg-surface-1 pr-12 disable-autofill-style placeholder:text-placeholder" + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + autoComplete="off" + /> + {showPassword?.retypePassword ? ( + handleShowPassword("retypePassword")} + /> + ) : ( + handleShowPassword("retypePassword")} + /> + )} +
+ {!!passwordFormData.confirm_password && + passwordFormData.password !== passwordFormData.confirm_password && + renderPasswordMatchError && Passwords don{"'"}t match} +
+ )} + +
+ {mode === EAuthModes.SIGN_IN ? ( + <> + + {isSMTPConfigured && ( + + )} + + ) : ( + + )} +
+
+ ); +}); diff --git a/plane-src/apps/space/components/account/auth-forms/unique-code.tsx b/plane-src/apps/space/components/account/auth-forms/unique-code.tsx new file mode 100644 index 0000000..c1dd212 --- /dev/null +++ b/plane-src/apps/space/components/account/auth-forms/unique-code.tsx @@ -0,0 +1,157 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useEffect, useState } from "react"; +import { CircleCheck, XCircle } from "lucide-react"; +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { AuthService } from "@plane/services"; +import { Input, Spinner } from "@plane/ui"; +// hooks +import useTimer from "@/hooks/use-timer"; +// types +import { EAuthModes } from "@/types/auth"; + +// services +const authService = new AuthService(); + +type TAuthUniqueCodeForm = { + mode: EAuthModes; + email: string; + nextPath: string | undefined; + handleEmailClear: () => void; + generateEmailUniqueCode: (email: string) => Promise<{ code: string } | undefined>; +}; + +type TUniqueCodeFormValues = { + email: string; + code: string; +}; + +const defaultValues: TUniqueCodeFormValues = { + email: "", + code: "", +}; + +export function AuthUniqueCodeForm(props: TAuthUniqueCodeForm) { + const { mode, email, nextPath, handleEmailClear, generateEmailUniqueCode } = props; + // derived values + const defaultResetTimerValue = 5; + // states + const [uniqueCodeFormData, setUniqueCodeFormData] = useState({ ...defaultValues, email }); + const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isSubmitting, setIsSubmitting] = useState(false); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0); + + const handleFormChange = (key: keyof TUniqueCodeFormValues, value: string) => + setUniqueCodeFormData((prev) => ({ ...prev, [key]: value })); + + const generateNewCode = async (email: string) => { + try { + setIsRequestingNewCode(true); + const uniqueCode = await generateEmailUniqueCode(email); + setResendCodeTimer(defaultResetTimerValue); + handleFormChange("code", uniqueCode?.code || ""); + setIsRequestingNewCode(false); + } catch { + setResendCodeTimer(0); + console.error("Error while requesting new code"); + setIsRequestingNewCode(false); + } + }; + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; + const isButtonDisabled = isRequestingNewCode || !uniqueCodeFormData.code || isSubmitting; + + return ( +
setIsSubmitting(true)} + onError={() => setIsSubmitting(false)} + > + + + +
+ +
+ handleFormChange("email", e.target.value)} + placeholder="name@company.com" + className={`h-10 w-full border-0 disable-autofill-style placeholder:text-placeholder`} + autoComplete="off" + disabled + /> + {uniqueCodeFormData.email.length > 0 && ( + + )} +
+
+ +
+ + handleFormChange("code", e.target.value)} + placeholder="123456" + className="h-10 w-full border border-subtle !bg-surface-1 pr-12 disable-autofill-style placeholder:text-placeholder" + autoComplete="off" + autoFocus + /> +
+

+ + Paste the code sent to your email +

+ +
+
+ +
+ +
+
+ ); +} diff --git a/plane-src/apps/space/components/account/terms-and-conditions.tsx b/plane-src/apps/space/components/account/terms-and-conditions.tsx new file mode 100644 index 0000000..1e9a71a --- /dev/null +++ b/plane-src/apps/space/components/account/terms-and-conditions.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +type Props = { + isSignUp?: boolean; +}; + +export function TermsAndConditions(props: Props) { + const { isSignUp = false } = props; + return ( + +

+ {isSignUp ? "By creating an account" : "By signing in"}, you agree to our{" \n"} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + {"."} +

+
+ ); +} diff --git a/plane-src/apps/space/components/account/user-logged-in.tsx b/plane-src/apps/space/components/account/user-logged-in.tsx new file mode 100644 index 0000000..42815b0 --- /dev/null +++ b/plane-src/apps/space/components/account/user-logged-in.tsx @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { PlaneLockup } from "@plane/propel/icons"; +// assets +import UserLoggedInImage from "@/app/assets/user-logged-in.svg?url"; +// components +import { PoweredBy } from "@/components/common/powered-by"; +import { UserAvatar } from "@/components/issues/navbar/user-avatar"; +// hooks +import { useUser } from "@/hooks/store/use-user"; + +export const UserLoggedIn = observer(function UserLoggedIn() { + // store hooks + const { data: user } = useUser(); + + if (!user) return null; + + return ( +
+
+ + +
+ +
+
+
+
+ User already logged in +
+
+

Nice! Just one more step.

+

+ Enter the public-share URL or link of the view or Page you are trying to see in the browser{"'"}s address + bar. +

+
+
+ +
+ ); +}); diff --git a/plane-src/apps/space/components/common/logo-spinner.tsx b/plane-src/apps/space/components/common/logo-spinner.tsx new file mode 100644 index 0000000..3f0f179 --- /dev/null +++ b/plane-src/apps/space/components/common/logo-spinner.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useTheme } from "next-themes"; +// assets +import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url"; +import LogoSpinnerLight from "@/app/assets/images/logo-spinner-light.gif?url"; + +export function LogoSpinner() { + const { resolvedTheme } = useTheme(); + + const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark; + + return ( +
+ logo +
+ ); +} diff --git a/plane-src/apps/space/components/common/powered-by.tsx b/plane-src/apps/space/components/common/powered-by.tsx new file mode 100644 index 0000000..05da512 --- /dev/null +++ b/plane-src/apps/space/components/common/powered-by.tsx @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { WEBSITE_URL } from "@plane/constants"; +// assets +import { PlaneLogo } from "@plane/propel/icons"; + +type TPoweredBy = { + disabled?: boolean; +}; + +export function PoweredBy(props: TPoweredBy) { + // props + const { disabled = false } = props; + + if (disabled || !WEBSITE_URL) return null; + + return ( + + +
+ Powered by Plane Publish +
+
+ ); +} diff --git a/plane-src/apps/space/components/common/project-logo.tsx b/plane-src/apps/space/components/common/project-logo.tsx new file mode 100644 index 0000000..b9540c0 --- /dev/null +++ b/plane-src/apps/space/components/common/project-logo.tsx @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// types +import type { TLogoProps } from "@plane/types"; +// helpers +import { cn } from "@plane/utils"; + +type Props = { + className?: string; + logo: TLogoProps; +}; + +export function ProjectLogo(props: Props) { + const { className, logo } = props; + + if (logo.in_use === "icon" && logo.icon) + return ( + + {logo.icon.name} + + ); + + if (logo.in_use === "emoji" && logo.emoji) + return ( + + {logo.emoji.value?.split("-").map((emoji) => String.fromCodePoint(parseInt(emoji, 10)))} + + ); + + return ; +} diff --git a/plane-src/apps/space/components/editor/embeds/mentions/index.ts b/plane-src/apps/space/components/editor/embeds/mentions/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/plane-src/apps/space/components/editor/embeds/mentions/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./root"; diff --git a/plane-src/apps/space/components/editor/embeds/mentions/root.tsx b/plane-src/apps/space/components/editor/embeds/mentions/root.tsx new file mode 100644 index 0000000..2c6914a --- /dev/null +++ b/plane-src/apps/space/components/editor/embeds/mentions/root.tsx @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { TCallbackMentionComponentProps } from "@plane/editor"; +// local components +import { EditorUserMention } from "./user"; + +export function EditorMentionsRoot(props: TCallbackMentionComponentProps) { + const { entity_identifier, entity_name } = props; + + switch (entity_name) { + case "user_mention": + return ; + default: + return null; + } +} diff --git a/plane-src/apps/space/components/editor/embeds/mentions/user.tsx b/plane-src/apps/space/components/editor/embeds/mentions/user.tsx new file mode 100644 index 0000000..24c817d --- /dev/null +++ b/plane-src/apps/space/components/editor/embeds/mentions/user.tsx @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// helpers +import { cn } from "@plane/utils"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +import { useUser } from "@/hooks/store/use-user"; + +type Props = { + id: string; +}; + +export const EditorUserMention = observer(function EditorUserMention(props: Props) { + const { id } = props; + // store hooks + const { data: currentUser } = useUser(); + const { getMemberById } = useMember(); + // derived values + const userDetails = getMemberById(id); + + if (!userDetails) { + return ( +
+ @deactivated user +
+ ); + } + + return ( +
+ @{userDetails?.member__display_name} +
+ ); +}); diff --git a/plane-src/apps/space/components/editor/lite-text-editor.tsx b/plane-src/apps/space/components/editor/lite-text-editor.tsx new file mode 100644 index 0000000..ecd08da --- /dev/null +++ b/plane-src/apps/space/components/editor/lite-text-editor.tsx @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +// plane imports +import { LiteTextEditorWithRef } from "@plane/editor"; +import type { EditorRefApi, ILiteTextEditorProps, TFileHandler } from "@plane/editor"; +import type { MakeOptional } from "@plane/types"; +import { cn, isCommentEmpty } from "@plane/utils"; +// helpers +import { getEditorFileHandlers } from "@/helpers/editor.helper"; +// hooks +import { useParseEditorContent } from "@/hooks/use-parse-editor-content"; +// plane web imports +import { useEditorFlagging } from "@/hooks/use-editor-flagging"; +// local imports +import { EditorMentionsRoot } from "./embeds/mentions"; +import { IssueCommentToolbar } from "./toolbar"; + +type LiteTextEditorWrapperProps = MakeOptional< + Omit, + "disabledExtensions" | "flaggedExtensions" | "getEditorMetaData" +> & { + anchor: string; + isSubmitting?: boolean; + showSubmitButton?: boolean; + workspaceId: string; +} & ( + | { + editable: false; + } + | { + editable: true; + uploadFile: TFileHandler["upload"]; + } + ); + +export const LiteTextEditor = React.forwardRef(function LiteTextEditor( + props: LiteTextEditorWrapperProps, + ref: React.ForwardedRef +) { + const { + anchor, + containerClassName, + disabledExtensions: additionalDisabledExtensions = [], + editable, + isSubmitting = false, + showSubmitButton = true, + workspaceId, + ...rest + } = props; + function isMutableRefObject(ref: React.ForwardedRef): ref is React.MutableRefObject { + return !!ref && typeof ref === "object" && "current" in ref; + } + // derived values + const isEmpty = isCommentEmpty(props.initialValue); + const editorRef = isMutableRefObject(ref) ? ref.current : null; + const { liteText: liteTextEditorExtensions } = useEditorFlagging(anchor); + // parse content + const { getEditorMetaData } = useParseEditorContent({ + anchor, + }); + + return ( +
+ "", + workspaceId, + })} + getEditorMetaData={getEditorMetaData} + mentionHandler={{ + renderComponent: (props) => , + }} + extendedEditorProps={{}} + {...rest} + // overriding the containerClassName to add relative class passed + containerClassName={cn(containerClassName, "relative")} + /> + {editable && ( + { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + editorRef?.executeMenuItemCommand({ + itemKey: item.itemKey, + ...item.extraProps, + }); + }} + isSubmitting={isSubmitting} + showSubmitButton={showSubmitButton} + handleSubmit={(e) => rest.onEnterKeyPress?.(e)} + isCommentEmpty={isEmpty} + editorRef={editorRef} + /> + )} +
+ ); +}); + +LiteTextEditor.displayName = "LiteTextEditor"; diff --git a/plane-src/apps/space/components/editor/rich-text-editor.tsx b/plane-src/apps/space/components/editor/rich-text-editor.tsx new file mode 100644 index 0000000..b7846e4 --- /dev/null +++ b/plane-src/apps/space/components/editor/rich-text-editor.tsx @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { forwardRef } from "react"; +// plane imports +import { RichTextEditorWithRef } from "@plane/editor"; +import type { EditorRefApi, IRichTextEditorProps, TFileHandler } from "@plane/editor"; +import type { MakeOptional } from "@plane/types"; +// helpers +import { getEditorFileHandlers } from "@/helpers/editor.helper"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +import { useParseEditorContent } from "@/hooks/use-parse-editor-content"; +// plane web imports +import { useEditorFlagging } from "@/hooks/use-editor-flagging"; +// local imports +import { EditorMentionsRoot } from "./embeds/mentions"; + +type RichTextEditorWrapperProps = MakeOptional< + Omit, + "disabledExtensions" | "flaggedExtensions" | "getEditorMetaData" +> & { + anchor: string; + workspaceId: string; +} & ( + | { + editable: false; + } + | { + editable: true; + uploadFile: TFileHandler["upload"]; + } + ); + +export const RichTextEditor = forwardRef(function RichTextEditor( + props: RichTextEditorWrapperProps, + ref: React.ForwardedRef +) { + const { + anchor, + containerClassName, + editable, + workspaceId, + disabledExtensions: additionalDisabledExtensions = [], + ...rest + } = props; + // store hooks + const { getMemberById } = useMember(); + // parse content + const { getEditorMetaData } = useParseEditorContent({ + anchor, + }); + // editor flaggings + const { richText: richTextEditorExtensions } = useEditorFlagging(anchor); + + return ( + , + getMentionedEntityDetails: (id: string) => ({ + display_name: getMemberById(id)?.member__display_name ?? "", + }), + }} + ref={ref} + disabledExtensions={[...richTextEditorExtensions.disabled, ...additionalDisabledExtensions]} + editable={editable} + fileHandler={getEditorFileHandlers({ + anchor, + uploadFile: editable ? props.uploadFile : async () => "", + workspaceId, + })} + getEditorMetaData={getEditorMetaData} + flaggedExtensions={richTextEditorExtensions.flagged} + extendedEditorProps={{}} + {...rest} + containerClassName={containerClassName} + editorClassName="min-h-[100px] py-2 overflow-hidden" + displayConfig={{ fontSize: "large-font" }} + /> + ); +}); + +RichTextEditor.displayName = "RichTextEditor"; diff --git a/plane-src/apps/space/components/editor/toolbar.tsx b/plane-src/apps/space/components/editor/toolbar.tsx new file mode 100644 index 0000000..ce2a0a2 --- /dev/null +++ b/plane-src/apps/space/components/editor/toolbar.tsx @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useEffect, useState, useCallback } from "react"; +// plane imports +import { TOOLBAR_ITEMS } from "@plane/editor"; +import type { ToolbarMenuItem, EditorRefApi } from "@plane/editor"; +import { Button } from "@plane/propel/button"; +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; + +type Props = { + executeCommand: (item: ToolbarMenuItem) => void; + handleSubmit: (event: React.MouseEvent) => void; + isCommentEmpty: boolean; + isSubmitting: boolean; + showSubmitButton: boolean; + editorRef: EditorRefApi | null; +}; + +const toolbarItems = TOOLBAR_ITEMS.lite; + +export function IssueCommentToolbar(props: Props) { + const { executeCommand, handleSubmit, isCommentEmpty, editorRef, isSubmitting, showSubmitButton } = props; + // states + const [activeStates, setActiveStates] = useState>({}); + + // Function to update active states + const updateActiveStates = useCallback(() => { + if (!editorRef) return; + const newActiveStates: Record = {}; + Object.values(toolbarItems) + .flat() + .forEach((item) => { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + newActiveStates[item.renderKey] = editorRef.isMenuItemActive({ + itemKey: item.itemKey, + ...item.extraProps, + }); + }); + setActiveStates(newActiveStates); + }, [editorRef]); + + // useEffect to call updateActiveStates when isActive prop changes + useEffect(() => { + if (!editorRef) return; + const unsubscribe = editorRef.onStateChange(updateActiveStates); + updateActiveStates(); + return () => unsubscribe(); + }, [editorRef, updateActiveStates]); + + return ( +
+
+
+ {Object.keys(toolbarItems).map((key, index) => ( +
+ {toolbarItems[key].map((item) => { + const isItemActive = activeStates[item.renderKey]; + + return ( + + {item.name} + {item.shortcut && {item.shortcut.join(" + ")}} +

+ } + > + +
+ ); + })} +
+ ))} +
+ {showSubmitButton && ( +
+ +
+ )} +
+
+ ); +} diff --git a/plane-src/apps/space/components/instance/instance-failure-view.tsx b/plane-src/apps/space/components/instance/instance-failure-view.tsx new file mode 100644 index 0000000..2bf810e --- /dev/null +++ b/plane-src/apps/space/components/instance/instance-failure-view.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useTheme } from "next-themes"; +import { Button } from "@plane/propel/button"; +// assets +import InstanceFailureDarkImage from "@/app/assets/instance/instance-failure-dark.svg?url"; +import InstanceFailureImage from "@/app/assets/instance/instance-failure.svg?url"; + +export function InstanceFailureView() { + const { resolvedTheme } = useTheme(); + + const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; + + const handleRetry = () => { + window.location.reload(); + }; + + return ( +
+
+
+ Plane instance failure image +

Unable to fetch instance details.

+

+ We were unable to fetch the details of the instance.
+ Fret not, it might just be a connectivity work items. +

+
+
+ +
+
+
+ ); +} diff --git a/plane-src/apps/space/components/issues/filters/applied-filters/filters-list.tsx b/plane-src/apps/space/components/issues/filters/applied-filters/filters-list.tsx new file mode 100644 index 0000000..6c64e1e --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/applied-filters/filters-list.tsx @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +import { CloseIcon } from "@plane/propel/icons"; +// types +import type { TFilters } from "@/types/issue"; +// components +import { AppliedPriorityFilters } from "./priority"; +import { AppliedStateFilters } from "./state"; + +type Props = { + appliedFilters: TFilters; + handleRemoveAllFilters: () => void; + handleRemoveFilter: (key: keyof TFilters, value: string | null) => void; +}; + +export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); + +export const AppliedFiltersList = observer(function AppliedFiltersList(props: Props) { + const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter } = props; + const { t } = useTranslation(); + + return ( +
+ {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof TFilters; + const filterValue = value as TFilters[keyof TFilters]; + + if (!filterValue) return; + + return ( +
+ {replaceUnderscoreIfSnakeCase(filterKey)} +
+ {filterKey === "priority" && ( + handleRemoveFilter("priority", val)} + values={(filterValue ?? []) as TFilters["priority"]} + /> + )} + + {filterKey === "state" && ( + handleRemoveFilter("state", val)} + values={filterValue ?? []} + /> + )} + + +
+
+ ); + })} + +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/filters/applied-filters/label.tsx b/plane-src/apps/space/components/issues/filters/applied-filters/label.tsx new file mode 100644 index 0000000..fce3799 --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/applied-filters/label.tsx @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { CloseIcon } from "@plane/propel/icons"; +// types +import type { IIssueLabel } from "@/types/issue"; + +type Props = { + handleRemove: (val: string) => void; + labels: IIssueLabel[] | undefined; + values: string[]; +}; + +export function AppliedLabelsFilters(props: Props) { + const { handleRemove, labels, values } = props; + + return ( + <> + {values.map((labelId) => { + const labelDetails = labels?.find((l) => l.id === labelId); + + if (!labelDetails) return null; + + return ( +
+ + {labelDetails.name} + +
+ ); + })} + + ); +} diff --git a/plane-src/apps/space/components/issues/filters/applied-filters/priority.tsx b/plane-src/apps/space/components/issues/filters/applied-filters/priority.tsx new file mode 100644 index 0000000..e28be12 --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/applied-filters/priority.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { CloseIcon, PriorityIcon } from "@plane/propel/icons"; +import type { TIssuePriorities } from "@plane/propel/icons"; + +type Props = { + handleRemove: (val: string) => void; + values: TIssuePriorities[]; +}; + +export function AppliedPriorityFilters(props: Props) { + const { handleRemove, values } = props; + + return ( + <> + {values?.map((priority) => ( +
+ + {priority} + +
+ ))} + + ); +} diff --git a/plane-src/apps/space/components/issues/filters/applied-filters/root.tsx b/plane-src/apps/space/components/issues/filters/applied-filters/root.tsx new file mode 100644 index 0000000..58ae481 --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/applied-filters/root.tsx @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback } from "react"; +import { cloneDeep } from "lodash-es"; +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +// hooks +import { useIssueFilter } from "@/hooks/store/use-issue-filter"; +// store +import type { TIssueLayout, TIssueQueryFilters } from "@/types/issue"; +// components +import { AppliedFiltersList } from "./filters-list"; + +type TIssueAppliedFilters = { + anchor: string; +}; + +export const IssueAppliedFilters = observer(function IssueAppliedFilters(props: TIssueAppliedFilters) { + const { anchor } = props; + // router + const router = useRouter(); + // store hooks + const { getIssueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter(); + // derived values + const issueFilters = getIssueFilters(anchor); + const activeLayout = issueFilters?.display_filters?.layout || undefined; + const userFilters = issueFilters?.filters || {}; + + const appliedFilters: any = {}; + Object.entries(userFilters).forEach(([key, value]) => { + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + appliedFilters[key] = value; + }); + + const updateRouteParams = useCallback( + (key: keyof TIssueQueryFilters, value: string[]) => { + const state = key === "state" ? value : (issueFilters?.filters?.state ?? []); + const priority = key === "priority" ? value : (issueFilters?.filters?.priority ?? []); + const labels = key === "labels" ? value : (issueFilters?.filters?.labels ?? []); + + const params: { + board: TIssueLayout | string; + priority?: string; + states?: string; + labels?: string; + } = { + board: activeLayout || "list", + }; + + if (priority.length > 0) params.priority = priority.join(","); + if (state.length > 0) params.states = state.join(","); + if (labels.length > 0) params.labels = labels.join(","); + + const qs = new URLSearchParams(params).toString(); + router.push(`/issues/${anchor}?${qs}`); + }, + [activeLayout, anchor, issueFilters, router] + ); + + const handleFilters = useCallback( + (key: keyof TIssueQueryFilters, value: string | null) => { + let newValues = cloneDeep(issueFilters?.filters?.[key]) ?? []; + + if (value === null) newValues = []; + else if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1); + + updateIssueFilters(anchor, "filters", key, newValues); + updateRouteParams(key, newValues); + }, + [anchor, issueFilters, updateIssueFilters, updateRouteParams] + ); + + const handleRemoveAllFilters = () => { + initIssueFilters( + anchor, + { + display_filters: { layout: activeLayout || "list" }, + filters: { + state: [], + priority: [], + labels: [], + }, + }, + true + ); + + router.push(`/issues/${anchor}?${`board=${activeLayout || "list"}`}`); + }; + + if (Object.keys(appliedFilters).length === 0) return null; + + return ( +
+ +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/filters/applied-filters/state.tsx b/plane-src/apps/space/components/issues/filters/applied-filters/state.tsx new file mode 100644 index 0000000..4acadba --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/applied-filters/state.tsx @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { EIconSize } from "@plane/constants"; +import { CloseIcon, StateGroupIcon } from "@plane/propel/icons"; +// hooks +import { useStates } from "@/hooks/store/use-state"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; +}; + +export const AppliedStateFilters = observer(function AppliedStateFilters(props: Props) { + const { handleRemove, values } = props; + + const { sortedStates: states } = useStates(); + + return ( + <> + {values.map((stateId) => { + const stateDetails = states?.find((s) => s.id === stateId); + + if (!stateDetails) return null; + + return ( +
+ + {stateDetails.name} + +
+ ); + })} + + ); +}); diff --git a/plane-src/apps/space/components/issues/filters/helpers/dropdown.tsx b/plane-src/apps/space/components/issues/filters/helpers/dropdown.tsx new file mode 100644 index 0000000..17f7ca6 --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/helpers/dropdown.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { Fragment, useState } from "react"; +import type { Placement } from "@popperjs/core"; +import { usePopper } from "react-popper"; +import { Popover, Transition } from "@headlessui/react"; +// ui +import { Button } from "@plane/propel/button"; + +type Props = { + children: React.ReactNode; + title?: string; + placement?: Placement; +}; + +export function FiltersDropdown(props: Props) { + const { children, title = "Dropdown", placement } = props; + + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "auto", + }); + + return ( + + {({ open }) => { + if (open) { + } + return ( + <> + + + + + +
+
{children}
+
+
+
+ + ); + }} +
+ ); +} diff --git a/plane-src/apps/space/components/issues/filters/helpers/filter-header.tsx b/plane-src/apps/space/components/issues/filters/helpers/filter-header.tsx new file mode 100644 index 0000000..72ed839 --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/helpers/filter-header.tsx @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +// icons +import { ChevronDownIcon, ChevronUpIcon } from "@plane/propel/icons"; + +interface IFilterHeader { + title: string; + isPreviewEnabled: boolean; + handleIsPreviewEnabled: () => void; +} + +export function FilterHeader({ title, isPreviewEnabled, handleIsPreviewEnabled }: IFilterHeader) { + return ( +
+
{title}
+ +
+ ); +} diff --git a/plane-src/apps/space/components/issues/filters/helpers/filter-option.tsx b/plane-src/apps/space/components/issues/filters/helpers/filter-option.tsx new file mode 100644 index 0000000..b16948f --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/helpers/filter-option.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +// plane imports +import { CheckIcon } from "@plane/propel/icons"; + +type Props = { + icon?: React.ReactNode; + isChecked: boolean; + title: React.ReactNode; + onClick?: () => void; + multiple?: boolean; +}; + +export function FilterOption(props: Props) { + const { icon, isChecked, multiple = true, onClick, title } = props; + + return ( + + ); +} diff --git a/plane-src/apps/space/components/issues/filters/index.ts b/plane-src/apps/space/components/issues/filters/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./root"; diff --git a/plane-src/apps/space/components/issues/filters/labels.tsx b/plane-src/apps/space/components/issues/filters/labels.tsx new file mode 100644 index 0000000..db235c5 --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/labels.tsx @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useState } from "react"; +// plane imports +import { Loader } from "@plane/ui"; +// types +import type { IIssueLabel } from "@/types/issue"; +// local imports +import { FilterHeader } from "./helpers/filter-header"; +import { FilterOption } from "./helpers/filter-option"; + +function LabelIcons({ color }: { color: string }) { + return ; +} + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + labels: IIssueLabel[] | undefined; + searchQuery: string; +}; + +export function FilterLabels(props: Props) { + const { appliedFilters, handleUpdate, labels, searchQuery } = props; + + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = labels?.filter((label) => label.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((label) => ( + handleUpdate(label?.id)} + icon={} + title={label.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +} diff --git a/plane-src/apps/space/components/issues/filters/priority.tsx b/plane-src/apps/space/components/issues/filters/priority.tsx new file mode 100644 index 0000000..06e4a53 --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/priority.tsx @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { ISSUE_PRIORITY_FILTERS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { PriorityIcon } from "@plane/propel/icons"; +// local imports +import { FilterHeader } from "./helpers/filter-header"; +import { FilterOption } from "./helpers/filter-option"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterPriority = observer(function FilterPriority(props: Props) { + const { appliedFilters, handleUpdate, searchQuery } = props; + + // hooks + const { t } = useTranslation(); + + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = ISSUE_PRIORITY_FILTERS.filter((p) => p.key.includes(searchQuery.toLowerCase())); + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((priority) => ( + handleUpdate(priority.key)} + icon={} + title={t(priority.titleTranslationKey)} + /> + )) + ) : ( +

{t("common.search.no_matches_found")}

+ )} +
+ )} + + ); +}); diff --git a/plane-src/apps/space/components/issues/filters/root.tsx b/plane-src/apps/space/components/issues/filters/root.tsx new file mode 100644 index 0000000..983f00a --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/root.tsx @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback } from "react"; +import { cloneDeep } from "lodash-es"; +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +// constants +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@plane/constants"; +// components +import { FiltersDropdown } from "@/components/issues/filters/helpers/dropdown"; +import { FilterSelection } from "@/components/issues/filters/selection"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueFilter } from "@/hooks/store/use-issue-filter"; +// types +import type { TIssueQueryFilters } from "@/types/issue"; + +type IssueFiltersDropdownProps = { + anchor: string; +}; + +export const IssueFiltersDropdown = observer(function IssueFiltersDropdown(props: IssueFiltersDropdownProps) { + const { anchor } = props; + // router + const router = useRouter(); + // hooks + const { getIssueFilters, updateIssueFilters } = useIssueFilter(); + // derived values + const issueFilters = getIssueFilters(anchor); + const activeLayout = issueFilters?.display_filters?.layout || undefined; + + const updateRouteParams = useCallback( + (key: keyof TIssueQueryFilters, value: string[]) => { + const state = key === "state" ? value : (issueFilters?.filters?.state ?? []); + const priority = key === "priority" ? value : (issueFilters?.filters?.priority ?? []); + const labels = key === "labels" ? value : (issueFilters?.filters?.labels ?? []); + + const { queryParam } = queryParamGenerator({ board: activeLayout, priority, state, labels }); + router.push(`/issues/${anchor}?${queryParam}`); + }, + [anchor, activeLayout, issueFilters, router] + ); + + const handleFilters = useCallback( + (key: keyof TIssueQueryFilters, value: string) => { + if (!value) return; + + const newValues = cloneDeep(issueFilters?.filters?.[key]) ?? []; + + if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + + updateIssueFilters(anchor, "filters", key, newValues); + updateRouteParams(key, newValues); + }, + [anchor, issueFilters, updateIssueFilters, updateRouteParams] + ); + + return ( +
+ + + +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/filters/selection.tsx b/plane-src/apps/space/components/issues/filters/selection.tsx new file mode 100644 index 0000000..c8f7e4e --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/selection.tsx @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import { SearchIcon, CloseIcon } from "@plane/propel/icons"; +// types +import type { IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue"; +// local imports +import { FilterPriority } from "./priority"; +import { FilterState } from "./state"; + +type Props = { + filters: IIssueFilterOptions; + handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void; + layoutDisplayFiltersOptions: TIssueFilterKeys[]; +}; + +export const FilterSelection = observer(function FilterSelection(props: Props) { + const { filters, handleFilters, layoutDisplayFiltersOptions } = props; + + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + const isFilterEnabled = (filter: keyof IIssueFilterOptions) => layoutDisplayFiltersOptions.includes(filter); + + return ( +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+ {/* priority */} + {isFilterEnabled("priority") && ( +
+ handleFilters("priority", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* state */} + {isFilterEnabled("state") && ( +
+ handleFilters("state", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* labels */} + {/* {isFilterEnabled("labels") && ( +
+ handleFilters("labels", val)} + labels={labels} + searchQuery={filtersSearchQuery} + /> +
+ )} */} +
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/filters/state.tsx b/plane-src/apps/space/components/issues/filters/state.tsx new file mode 100644 index 0000000..f9275a2 --- /dev/null +++ b/plane-src/apps/space/components/issues/filters/state.tsx @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +// ui +import { EIconSize } from "@plane/constants"; +import { StateGroupIcon } from "@plane/propel/icons"; +import { Loader } from "@plane/ui"; +// hooks +import { useStates } from "@/hooks/store/use-state"; +// local imports +import { FilterHeader } from "./helpers/filter-header"; +import { FilterOption } from "./helpers/filter-option"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterState = observer(function FilterState(props: Props) { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const { sortedStates: states } = useStates(); + + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = states?.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((state) => ( + handleUpdate(state.id)} + icon={} + title={state.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/error.tsx b/plane-src/apps/space/components/issues/issue-layouts/error.tsx new file mode 100644 index 0000000..40e4f55 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/error.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// assets +import SomethingWentWrongImage from "@/app/assets/something-went-wrong.svg?url"; + +export function SomethingWentWrongError() { + return ( +
+
+
+
+ Oops! Something went wrong +
+
+

Oops! Something went wrong.

+

The public board does not exist. Please check the URL.

+
+
+ ); +} diff --git a/plane-src/apps/space/components/issues/issue-layouts/index.ts b/plane-src/apps/space/components/issues/issue-layouts/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./root"; diff --git a/plane-src/apps/space/components/issues/issue-layouts/issue-layout-HOC.tsx b/plane-src/apps/space/components/issues/issue-layouts/issue-layout-HOC.tsx new file mode 100644 index 0000000..6a9a719 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/issue-layout-HOC.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import type { TLoader } from "@plane/types"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; + +interface Props { + children: string | React.ReactNode | React.ReactNode[]; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getIssueLoader: (groupId?: string, subGroupId?: string) => TLoader; +} + +export const IssueLayoutHOC = observer(function IssueLayoutHOC(props: Props) { + const { getIssueLoader, getGroupIssueCount } = props; + + const issueCount = getGroupIssueCount(undefined, undefined, false); + + if (getIssueLoader() === "init-loader" || issueCount === undefined) { + return ( +
+ +
+ ); + } + + if (getGroupIssueCount(undefined, undefined, false) === 0) { + return
No work items found
; + } + + return <>{props.children}; +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/plane-src/apps/space/components/issues/issue-layouts/kanban/base-kanban-root.tsx new file mode 100644 index 0000000..6da0ce2 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useMemo, useRef } from "react"; +import { debounce } from "lodash-es"; +import { observer } from "mobx-react"; +// types +import type { IIssueDisplayProperties } from "@plane/types"; +// components +import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC"; +// hooks +import { useIssue } from "@/hooks/store/use-issue"; + +import { KanBan } from "./default"; + +type Props = { + anchor: string; +}; +export const IssueKanbanLayoutRoot = observer(function IssueKanbanLayoutRoot(props: Props) { + const { anchor } = props; + // store hooks + const { groupedIssueIds, getIssueLoader, fetchNextPublicIssues, getGroupIssueCount, getPaginationData } = useIssue(); + + const displayProperties: IIssueDisplayProperties = useMemo( + () => ({ + key: true, + state: true, + labels: true, + priority: true, + due_date: true, + }), + [] + ); + + const fetchMoreIssues = useCallback( + (groupId?: string, subgroupId?: string) => { + if (getIssueLoader(groupId, subgroupId) !== "pagination") { + fetchNextPublicIssues(anchor, groupId, subgroupId); + } + }, + [anchor, getIssueLoader, fetchNextPublicIssues] + ); + + const debouncedFetchMoreIssues = debounce( + (groupId?: string, subgroupId?: string) => fetchMoreIssues(groupId, subgroupId), + 300, + { leading: true, trailing: false } + ); + + const scrollableContainerRef = useRef(null); + + return ( + +
+
+
+ +
+
+
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/kanban/block-reactions.tsx b/plane-src/apps/space/components/issues/issue-layouts/kanban/block-reactions.tsx new file mode 100644 index 0000000..78e16a4 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/kanban/block-reactions.tsx @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane utils +import { cn } from "@plane/utils"; +// components +import { IssueEmojiReactions } from "@/components/issues/reactions/issue-emoji-reactions"; +import { IssueVotes } from "@/components/issues/reactions/issue-vote-reactions"; +// hooks +import { usePublish } from "@/hooks/store/publish"; + +type Props = { + issueId: string; +}; +export const BlockReactions = observer(function BlockReactions(props: Props) { + const { issueId } = props; + const { anchor } = useParams(); + const { canVote, canReact } = usePublish(anchor.toString()); + + // if the user cannot vote or react then return empty + if (!canVote && !canReact) return <>; + + return ( +
+
+ {canVote && ( +
+ +
+ )} + {canReact && ( +
+ +
+ )} +
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/kanban/block.tsx b/plane-src/apps/space/components/issues/issue-layouts/kanban/block.tsx new file mode 100644 index 0000000..3ad4c71 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/kanban/block.tsx @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { MutableRefObject } from "react"; +import { observer } from "mobx-react"; +import { Link } from "react-router"; +import { useParams, useSearchParams } from "next/navigation"; +// plane types +import { Tooltip } from "@plane/propel/tooltip"; +import type { IIssueDisplayProperties } from "@plane/types"; +// plane ui +// plane utils +import { cn } from "@plane/utils"; +// components +import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +// +import type { IIssue } from "@/types/issue"; +import { IssueProperties } from "../properties/all-properties"; +import { getIssueBlockId } from "../utils"; +import { BlockReactions } from "./block-reactions"; + +interface IssueBlockProps { + issueId: string; + groupId: string; + subGroupId: string; + displayProperties: IIssueDisplayProperties | undefined; + scrollableContainerRef?: MutableRefObject; +} + +interface IssueDetailsBlockProps { + issue: IIssue; + displayProperties: IIssueDisplayProperties | undefined; +} + +const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props: IssueDetailsBlockProps) { + const { issue, displayProperties } = props; + const { anchor } = useParams(); + // hooks + const { project_details } = usePublish(anchor.toString()); + + return ( +
+ +
+
+ {project_details?.identifier}-{issue.sequence_id} +
+
+
+ +
+ + {issue.name} + +
+ + +
+ ); +}); + +export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueBlockProps) { + const { issueId, groupId, subGroupId, displayProperties } = props; + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board"); + // hooks + const { setPeekId, getIsIssuePeeked, getIssueById } = useIssueDetails(); + + const handleIssuePeekOverview = () => { + setPeekId(issueId); + }; + + const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId }); + + const issue = getIssueById(issueId); + + if (!issue) return null; + + return ( +
+
+ + + + +
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/kanban/blocks-list.tsx b/plane-src/apps/space/components/issues/issue-layouts/kanban/blocks-list.tsx new file mode 100644 index 0000000..319bdf7 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/kanban/blocks-list.tsx @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { MutableRefObject } from "react"; +import { observer } from "mobx-react"; +//types +import type { IIssueDisplayProperties } from "@plane/types"; +// components +import { KanbanIssueBlock } from "./block"; + +interface IssueBlocksListProps { + subGroupId: string; + groupId: string; + issueIds: string[]; + displayProperties: IIssueDisplayProperties | undefined; + scrollableContainerRef?: MutableRefObject; +} + +export const KanbanIssueBlocksList = observer(function KanbanIssueBlocksList(props: IssueBlocksListProps) { + const { subGroupId, groupId, issueIds, displayProperties, scrollableContainerRef } = props; + + return ( + <> + {issueIds && issueIds.length > 0 + ? issueIds.map((issueId) => { + if (!issueId) return null; + + let draggableId = issueId; + if (groupId) draggableId = `${draggableId}__${groupId}`; + if (subGroupId) draggableId = `${draggableId}__${subGroupId}`; + + return ( + + ); + }) + : null} + + ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/kanban/default.tsx b/plane-src/apps/space/components/issues/issue-layouts/kanban/default.tsx new file mode 100644 index 0000000..fa934ae --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/kanban/default.tsx @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { MutableRefObject } from "react"; +import { isNil } from "lodash-es"; +import { observer } from "mobx-react"; +// types +import type { + GroupByColumnTypes, + IGroupByColumn, + TGroupedIssues, + IIssueDisplayProperties, + TSubGroupedIssues, + TIssueGroupByOptions, + TPaginationData, + TLoader, +} from "@plane/types"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useLabel } from "@/hooks/store/use-label"; +import { useMember } from "@/hooks/store/use-member"; +import { useModule } from "@/hooks/store/use-module"; +import { useStates } from "@/hooks/store/use-state"; +// +import { getGroupByColumns } from "../utils"; +// components +import { HeaderGroupByCard } from "./headers/group-by-card"; +import { KanbanGroup } from "./kanban-group"; + +export interface IKanBan { + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + groupBy: TIssueGroupByOptions | undefined; + subGroupId?: string; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string, subGroupId?: string) => TLoader; + scrollableContainerRef?: MutableRefObject; + showEmptyGroup?: boolean; +} + +export const KanBan = observer(function KanBan(props: IKanBan) { + const { + groupedIssueIds, + displayProperties, + subGroupBy, + groupBy, + subGroupId = "null", + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + scrollableContainerRef, + showEmptyGroup = true, + } = props; + + const member = useMember(); + const label = useLabel(); + const cycle = useCycle(); + const modules = useModule(); + const state = useStates(); + + const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member); + + if (!groupList) return null; + + const visibilityGroupBy = (_list: IGroupByColumn): { showGroup: boolean; showIssues: boolean } => { + const groupVisibility = { + showGroup: true, + showIssues: true, + }; + + if (!showEmptyGroup) { + groupVisibility.showGroup = (getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0; + } + return groupVisibility; + }; + + return ( +
+ {groupList?.map((subList) => { + const groupByVisibilityToggle = visibilityGroupBy(subList); + + if (groupByVisibilityToggle.showGroup === false) return <>; + return ( +
+ {isNil(subGroupBy) && ( +
+ +
+ )} + + {groupByVisibilityToggle.showIssues && ( + + )} +
+ ); + })} +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/plane-src/apps/space/components/issues/issue-layouts/kanban/headers/group-by-card.tsx new file mode 100644 index 0000000..b66127c --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { Circle } from "lucide-react"; +// types +import type { TIssueGroupByOptions } from "@plane/types"; + +interface IHeaderGroupByCard { + groupBy: TIssueGroupByOptions | undefined; + icon?: React.ReactNode; + title: string; + count: number; +} + +export const HeaderGroupByCard = observer(function HeaderGroupByCard(props: IHeaderGroupByCard) { + const { icon, title, count } = props; + + return ( + <> +
+
+ {icon ? icon : } +
+
+
{title}
+
{count || 0}
+
+
+ + ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx b/plane-src/apps/space/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx new file mode 100644 index 0000000..6f429ae --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { Circle } from "lucide-react"; +import { ChevronDownIcon, ChevronUpIcon } from "@plane/propel/icons"; +// mobx + +interface IHeaderSubGroupByCard { + icon?: React.ReactNode; + title: string; + count: number; + isExpanded: boolean; + toggleExpanded: () => void; +} + +export const HeaderSubGroupByCard = observer(function HeaderSubGroupByCard(props: IHeaderSubGroupByCard) { + const { icon, title, count, isExpanded, toggleExpanded } = props; + return ( +
toggleExpanded()} + > +
+ {isExpanded ? : } +
+ +
+ {icon ? icon : } +
+ +
+
{title}
+
{count || 0}
+
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/kanban/kanban-group.tsx b/plane-src/apps/space/components/issues/issue-layouts/kanban/kanban-group.tsx new file mode 100644 index 0000000..360b5e9 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { MutableRefObject } from "react"; +import { forwardRef, useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +//types +import type { + TGroupedIssues, + IIssueDisplayProperties, + TSubGroupedIssues, + TIssueGroupByOptions, + TPaginationData, + TLoader, +} from "@plane/types"; +import { cn } from "@plane/utils"; +// hooks +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; +// local imports +import { KanbanIssueBlocksList } from "./blocks-list"; + +interface IKanbanGroup { + groupId: string; + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + subGroupId: string; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string, subGroupId?: string) => TLoader; + scrollableContainerRef?: MutableRefObject; +} + +// Loader components +const KanbanIssueBlockLoader = forwardRef(function KanbanIssueBlockLoader( + props: Record, + ref: React.ForwardedRef +) { + return ( + + ); +}); +KanbanIssueBlockLoader.displayName = "KanbanIssueBlockLoader"; + +export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) { + const { + groupId, + subGroupId, + subGroupBy, + displayProperties, + groupedIssueIds, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + scrollableContainerRef, + } = props; + + // hooks + const [intersectionElement, setIntersectionElement] = useState(null); + const columnRef = useRef(null); + + const containerRef = subGroupBy && scrollableContainerRef ? scrollableContainerRef : columnRef; + + const loadMoreIssuesInThisGroup = useCallback(() => { + loadMoreIssues(groupId, subGroupId === "null" ? undefined : subGroupId); + }, [loadMoreIssues, groupId, subGroupId]); + + const isPaginating = !!getIssueLoader(groupId, subGroupId); + + useIntersectionObserver( + containerRef, + isPaginating ? null : intersectionElement, + loadMoreIssuesInThisGroup, + `0% 100% 100% 100%` + ); + + const isSubGroup = !!subGroupId && subGroupId !== "null"; + + const issueIds = isSubGroup + ? ((groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[subGroupId] ?? []) + : ((groupedIssueIds as TGroupedIssues)?.[groupId] ?? []); + + const groupIssueCount = getGroupIssueCount(groupId, subGroupId, false) ?? 0; + const nextPageResults = getPaginationData(groupId, subGroupId)?.nextPageResults; + + const loadMore = isPaginating ? ( + + ) : ( +
+ {" "} + Load More ↓ +
+ ); + + const shouldLoadMore = nextPageResults === undefined ? issueIds?.length < groupIssueCount : !!nextPageResults; + + return ( +
+ + + {shouldLoadMore && + (isSubGroup ? ( + <>{loadMore} + ) : ( +
+ {Array.from({ length: 2 }).map((_, index) => ( + + ))} + +
+ ))} +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/kanban/swimlanes.tsx b/plane-src/apps/space/components/issues/issue-layouts/kanban/swimlanes.tsx new file mode 100644 index 0000000..977bf5b --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -0,0 +1,310 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { MutableRefObject } from "react"; +import { useState } from "react"; +import { observer } from "mobx-react"; +// types +import type { + GroupByColumnTypes, + IGroupByColumn, + TGroupedIssues, + IIssueDisplayProperties, + TSubGroupedIssues, + TIssueGroupByOptions, + TIssueOrderByOptions, + TPaginationData, + TLoader, +} from "@plane/types"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useLabel } from "@/hooks/store/use-label"; +import { useMember } from "@/hooks/store/use-member"; +import { useModule } from "@/hooks/store/use-module"; +import { useStates } from "@/hooks/store/use-state"; +// +import { getGroupByColumns } from "../utils"; +import { KanBan } from "./default"; +import { HeaderGroupByCard } from "./headers/group-by-card"; +import { HeaderSubGroupByCard } from "./headers/sub-group-by-card"; + +export interface IKanBanSwimLanes { + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + groupBy: TIssueGroupByOptions | undefined; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string, subGroupId?: string) => TLoader; + showEmptyGroup: boolean; + scrollableContainerRef?: MutableRefObject; + orderBy: TIssueOrderByOptions | undefined; +} + +export const KanBanSwimLanes = observer(function KanBanSwimLanes(props: IKanBanSwimLanes) { + const { + groupedIssueIds, + displayProperties, + subGroupBy, + groupBy, + orderBy, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + showEmptyGroup, + scrollableContainerRef, + } = props; + + const member = useMember(); + const label = useLabel(); + const cycle = useCycle(); + const modules = useModule(); + const state = useStates(); + + const groupByList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member); + const subGroupByList = getGroupByColumns(subGroupBy as GroupByColumnTypes, cycle, modules, label, state, member); + + if (!groupByList || !subGroupByList) return null; + + return ( +
+
+ +
+ + {subGroupBy && ( + + )} +
+ ); +}); + +interface ISubGroupSwimlaneHeader { + subGroupBy: TIssueGroupByOptions | undefined; + groupBy: TIssueGroupByOptions | undefined; + groupList: IGroupByColumn[]; + showEmptyGroup: boolean; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; +} + +const visibilitySubGroupByGroupCount = (subGroupIssueCount: number, showEmptyGroup: boolean): boolean => { + let subGroupHeaderVisibility = true; + + if (showEmptyGroup) subGroupHeaderVisibility = true; + else { + if (subGroupIssueCount > 0) subGroupHeaderVisibility = true; + else subGroupHeaderVisibility = false; + } + + return subGroupHeaderVisibility; +}; + +const SubGroupSwimlaneHeader = observer(function SubGroupSwimlaneHeader({ + subGroupBy, + groupBy, + groupList, + showEmptyGroup, + getGroupIssueCount, +}: ISubGroupSwimlaneHeader) { + return ( +
+ {groupList && + groupList.length > 0 && + groupList.map((group: IGroupByColumn) => { + const groupCount = getGroupIssueCount(group.id, undefined, false) ?? 0; + + const subGroupByVisibilityToggle = visibilitySubGroupByGroupCount(groupCount, showEmptyGroup); + + if (subGroupByVisibilityToggle === false) return <>; + return ( +
+ +
+ ); + })} +
+ ); +}); + +interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + showEmptyGroup: boolean; + displayProperties: IIssueDisplayProperties | undefined; + orderBy: TIssueOrderByOptions | undefined; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string, subGroupId?: string) => TLoader; + scrollableContainerRef?: MutableRefObject; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; +} + +const SubGroupSwimlane = observer(function SubGroupSwimlane(props: ISubGroupSwimlane) { + const { + groupedIssueIds, + subGroupBy, + groupBy, + groupList, + displayProperties, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + showEmptyGroup, + scrollableContainerRef, + } = props; + + return ( +
+ {groupList && + groupList.length > 0 && + groupList.map((group: IGroupByColumn) => ( + + ))} +
+ ); +}); + +interface ISubGroup { + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + showEmptyGroup: boolean; + displayProperties: IIssueDisplayProperties | undefined; + groupBy: TIssueGroupByOptions | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + group: IGroupByColumn; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string, subGroupId?: string) => TLoader; + scrollableContainerRef?: MutableRefObject; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; +} + +const SubGroup = observer(function SubGroup(props: ISubGroup) { + const { + groupedIssueIds, + subGroupBy, + groupBy, + group, + displayProperties, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + showEmptyGroup, + scrollableContainerRef, + } = props; + + const [isExpanded, setIsExpanded] = useState(true); + + const toggleExpanded = () => { + setIsExpanded((prevState) => !prevState); + }; + + const visibilitySubGroupBy = ( + _list: IGroupByColumn, + subGroupCount: number + ): { showGroup: boolean; showIssues: boolean } => { + const subGroupVisibility = { + showGroup: true, + showIssues: true, + }; + if (showEmptyGroup) subGroupVisibility.showGroup = true; + else { + if (subGroupCount > 0) subGroupVisibility.showGroup = true; + else subGroupVisibility.showGroup = false; + } + return subGroupVisibility; + }; + + const issueCount = getGroupIssueCount(undefined, group.id, true) ?? 0; + const subGroupByVisibilityToggle = visibilitySubGroupBy(group, issueCount); + if (subGroupByVisibilityToggle.showGroup === false) return <>; + + return ( + <> +
+
+
+ +
+
+ + {subGroupByVisibilityToggle.showIssues && isExpanded && ( +
+ +
+ )} +
+ + ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/list/base-list-root.tsx b/plane-src/apps/space/components/issues/issue-layouts/list/base-list-root.tsx new file mode 100644 index 0000000..fbc1347 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/list/base-list-root.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useMemo } from "react"; +import { observer } from "mobx-react"; +// types +import type { IIssueDisplayProperties, TGroupedIssues } from "@plane/types"; +// constants +// components +import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC"; +// hooks +import { useIssue } from "@/hooks/store/use-issue"; +import { List } from "./default"; + +type Props = { + anchor: string; +}; + +export const IssuesListLayoutRoot = observer(function IssuesListLayoutRoot(props: Props) { + const { anchor } = props; + // store hooks + const { + groupedIssueIds: storeGroupedIssueIds, + fetchNextPublicIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + } = useIssue(); + + const groupedIssueIds = storeGroupedIssueIds as TGroupedIssues | undefined; + // auth + const displayProperties: IIssueDisplayProperties = useMemo( + () => ({ + key: true, + state: true, + labels: true, + priority: true, + due_date: true, + }), + [] + ); + + const loadMoreIssues = useCallback( + (groupId?: string) => { + fetchNextPublicIssues(anchor, groupId); + }, + [anchor, fetchNextPublicIssues] + ); + + return ( + +
+ +
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/list/block.tsx b/plane-src/apps/space/components/issues/issue-layouts/list/block.tsx new file mode 100644 index 0000000..24fc5f2 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/list/block.tsx @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useRef } from "react"; +import { observer } from "mobx-react"; +import { Link } from "react-router"; +import { useParams, useSearchParams } from "next/navigation"; +// plane types +import { Tooltip } from "@plane/propel/tooltip"; +import type { IIssueDisplayProperties } from "@plane/types"; +// plane ui +// plane utils +import { cn } from "@plane/utils"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +// +import { IssueProperties } from "../properties/all-properties"; + +interface IssueBlockProps { + issueId: string; + groupId: string; + displayProperties: IIssueDisplayProperties | undefined; +} + +export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) { + const { anchor } = useParams(); + const { issueId, displayProperties } = props; + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board"); + // ref + const issueRef = useRef(null); + // hooks + const { project_details } = usePublish(anchor.toString()); + const { getIsIssuePeeked, setPeekId, getIssueById } = useIssueDetails(); + + const handleIssuePeekOverview = () => { + setPeekId(issueId); + }; + + const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId }); + + const issue = getIssueById(issueId); + + if (!issue) return null; + + const projectIdentifier = project_details?.identifier; + + return ( +
+
+
+
+ {displayProperties && displayProperties?.key && ( +
+ {projectIdentifier}-{issue.sequence_id} +
+ )} +
+ + + +

{issue.name}

+
+ +
+
+
+ +
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/list/blocks-list.tsx b/plane-src/apps/space/components/issues/issue-layouts/list/blocks-list.tsx new file mode 100644 index 0000000..2580afb --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/list/blocks-list.tsx @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { MutableRefObject } from "react"; +// types +import type { IIssueDisplayProperties } from "@plane/types"; +import { IssueBlock } from "./block"; + +interface Props { + issueIds: string[] | undefined; + groupId: string; + displayProperties?: IIssueDisplayProperties; + containerRef: MutableRefObject; +} + +export function IssueBlocksList(props: Props) { + const { issueIds = [], groupId, displayProperties } = props; + + return ( +
+ {issueIds?.map((issueId) => ( + + ))} +
+ ); +} diff --git a/plane-src/apps/space/components/issues/issue-layouts/list/default.tsx b/plane-src/apps/space/components/issues/issue-layouts/list/default.tsx new file mode 100644 index 0000000..f8f545b --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/list/default.tsx @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useRef } from "react"; +import { observer } from "mobx-react"; +// types +import type { + GroupByColumnTypes, + TGroupedIssues, + IIssueDisplayProperties, + TIssueGroupByOptions, + TPaginationData, + TLoader, +} from "@plane/types"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useLabel } from "@/hooks/store/use-label"; +import { useMember } from "@/hooks/store/use-member"; +import { useModule } from "@/hooks/store/use-module"; +import { useStates } from "@/hooks/store/use-state"; +// +import { getGroupByColumns } from "../utils"; +import { ListGroup } from "./list-group"; + +export interface IList { + groupedIssueIds: TGroupedIssues; + groupBy: TIssueGroupByOptions | undefined; + displayProperties: IIssueDisplayProperties | undefined; + showEmptyGroup?: boolean; + loadMoreIssues: (groupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string, subGroupId?: string) => TLoader; +} + +export const List = observer(function List(props: IList) { + const { + groupedIssueIds, + groupBy, + displayProperties, + showEmptyGroup, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + } = props; + + const containerRef = useRef(null); + + const member = useMember(); + const label = useLabel(); + const cycle = useCycle(); + const modules = useModule(); + const state = useStates(); + + const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member, true); + + if (!groupList) return null; + + return ( +
+ {groupList && ( + <> +
+ {groupList.map((group) => ( + + ))} +
+ + )} +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/list/headers/group-by-card.tsx b/plane-src/apps/space/components/issues/issue-layouts/list/headers/group-by-card.tsx new file mode 100644 index 0000000..5f37484 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { CircleDashed } from "lucide-react"; + +interface IHeaderGroupByCard { + groupID: string; + icon?: React.ReactNode; + title: string; + count: number; + toggleListGroup: (id: string) => void; +} + +export const HeaderGroupByCard = observer(function HeaderGroupByCard(props: IHeaderGroupByCard) { + const { groupID, icon, title, count, toggleListGroup } = props; + + return ( + <> +
toggleListGroup(groupID)} + role="button" + > +
+ {icon ?? } +
+ +
+
{title}
+
{count || 0}
+
+
+ + ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/list/list-group.tsx b/plane-src/apps/space/components/issues/issue-layouts/list/list-group.tsx new file mode 100644 index 0000000..26bc499 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/list/list-group.tsx @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { MutableRefObject } from "react"; +import { Fragment, forwardRef, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// plane types +import type { + IGroupByColumn, + TIssueGroupByOptions, + IIssueDisplayProperties, + TPaginationData, + TLoader, +} from "@plane/types"; +// plane utils +import { cn } from "@plane/utils"; +// hooks +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; +// +import { IssueBlocksList } from "./blocks-list"; +import { HeaderGroupByCard } from "./headers/group-by-card"; + +interface Props { + groupIssueIds: string[] | undefined; + group: IGroupByColumn; + groupBy: TIssueGroupByOptions | undefined; + displayProperties: IIssueDisplayProperties | undefined; + containerRef: MutableRefObject; + showEmptyGroup?: boolean; + loadMoreIssues: (groupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string, subGroupId?: string) => TLoader; +} + +// List loader component +const ListLoaderItemRow = forwardRef(function ListLoaderItemRow( + props: Record, + ref: React.ForwardedRef +) { + return ( +
+
+ + +
+
+ {[...Array(6)].map((_, index) => ( + + + + ))} +
+
+ ); +}); +ListLoaderItemRow.displayName = "ListLoaderItemRow"; + +export const ListGroup = observer(function ListGroup(props: Props) { + const { + groupIssueIds = [], + group, + groupBy, + displayProperties, + containerRef, + showEmptyGroup, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + } = props; + const [isExpanded, setIsExpanded] = useState(true); + const groupRef = useRef(null); + // hooks + const { t } = useTranslation(); + + const [intersectionElement, setIntersectionElement] = useState(null); + + const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0; + const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults; + const isPaginating = !!getIssueLoader(group.id); + + useIntersectionObserver(containerRef, isPaginating ? null : intersectionElement, loadMoreIssues, `100% 0% 100% 0%`); + + const shouldLoadMore = + nextPageResults === undefined && groupIssueCount !== undefined && groupIssueIds + ? groupIssueIds.length < groupIssueCount + : !!nextPageResults; + + const loadMore = isPaginating ? ( + + ) : ( +
loadMoreIssues(group.id)} + role="button" + > + {t("common.load_more")} ↓ +
+ ); + + const validateEmptyIssueGroups = (issueCount: number = 0) => { + if (!showEmptyGroup && issueCount <= 0) return false; + return true; + }; + + const toggleListGroup = () => { + setIsExpanded((prevState) => !prevState); + }; + + const shouldExpand = (!!groupIssueCount && isExpanded) || !groupBy; + + return validateEmptyIssueGroups(groupIssueCount) ? ( +
+
+ +
+ {shouldExpand && ( +
+ {groupIssueIds && ( + + )} + + {shouldLoadMore && (groupBy ? <>{loadMore} : )} +
+ )} +
+ ) : null; +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/properties/all-properties.tsx b/plane-src/apps/space/components/issues/issue-layouts/properties/all-properties.tsx new file mode 100644 index 0000000..9d9c9da --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/properties/all-properties.tsx @@ -0,0 +1,185 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { Paperclip } from "lucide-react"; +import { LinkIcon, ViewsIcon } from "@plane/propel/icons"; +// plane imports +import { Tooltip } from "@plane/propel/tooltip"; +import type { IIssueDisplayProperties } from "@plane/types"; +import { cn } from "@plane/utils"; +// components +import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; +//// hooks +import type { IIssue } from "@/types/issue"; +import { IssueBlockCycle } from "./cycle"; +import { IssueBlockDate } from "./due-date"; +import { IssueBlockLabels } from "./labels"; +import { IssueBlockMembers } from "./member"; +import { IssueBlockModules } from "./modules"; +import { IssueBlockPriority } from "./priority"; +import { IssueBlockState } from "./state"; + +export interface IIssueProperties { + issue: IIssue; + displayProperties: IIssueDisplayProperties | undefined; + className: string; +} + +export const IssueProperties = observer(function IssueProperties(props: IIssueProperties) { + const { issue, displayProperties, className } = props; + + if (!displayProperties || !issue.project_id) return null; + + const minDate = getDate(issue.start_date); + minDate?.setDate(minDate.getDate()); + + const maxDate = getDate(issue.target_date); + maxDate?.setDate(maxDate.getDate()); + + return ( +
+ {/* basic properties */} + {/* state */} + {issue.state_id && ( + +
+ +
+
+ )} + + {/* priority */} + +
+ +
+
+ + {/* label */} + +
+ +
+
+ + {/* start date */} + {issue?.start_date && ( + +
+ +
+
+ )} + + {/* target/due date */} + {issue?.target_date && ( + +
+ +
+
+ )} + + {/* assignee */} + +
+ +
+
+ + {/* modules */} + {issue.module_ids && issue.module_ids.length > 0 && ( + +
+ +
+
+ )} + + {/* cycles */} + {issue.cycle_id && ( + +
+ +
+
+ )} + + {/* estimates */} + {/* {projectId && areEstimateEnabledByProjectId(projectId?.toString()) && ( + +
+ +
+
+ )} */} + + {/* extra render properties */} + {/* sub-issues */} + !!properties.sub_issue_count && !!issue.sub_issues_count} + > + +
+ +
{issue.sub_issues_count}
+
+
+
+ + {/* attachments */} + !!properties.attachment_count && !!issue.attachment_count} + > + +
+ +
{issue.attachment_count}
+
+
+
+ + {/* link */} + !!properties.link && !!issue.link_count} + > + +
+ +
{issue.link_count}
+
+
+
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/properties/cycle.tsx b/plane-src/apps/space/components/issues/issue-layouts/properties/cycle.tsx new file mode 100644 index 0000000..4f70bac --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/properties/cycle.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane ui +import { CycleIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +// plane utils +import { cn } from "@plane/utils"; +//hooks +import { useCycle } from "@/hooks/store/use-cycle"; + +type Props = { + cycleId: string | undefined; + shouldShowBorder?: boolean; +}; + +export const IssueBlockCycle = observer(function IssueBlockCycle({ cycleId, shouldShowBorder = true }: Props) { + const { getCycleById } = useCycle(); + + const cycle = getCycleById(cycleId); + + return ( + +
+
+ +
{cycle?.name ?? "No Cycle"}
+
+
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/properties/due-date.tsx b/plane-src/apps/space/components/issues/issue-layouts/properties/due-date.tsx new file mode 100644 index 0000000..7fa853b --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/properties/due-date.tsx @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { DueDatePropertyIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; +// helpers +import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; +// hooks +import { useStates } from "@/hooks/store/use-state"; + +type Props = { + due_date: string | undefined; + stateId: string | undefined; + shouldHighLight?: boolean; + shouldShowBorder?: boolean; +}; + +export const IssueBlockDate = observer(function IssueBlockDate(props: Props) { + const { due_date, stateId, shouldHighLight = true, shouldShowBorder = true } = props; + const { getStateById } = useStates(); + + const state = getStateById(stateId); + + const formattedDate = renderFormattedDate(due_date); + + return ( + +
+ + {formattedDate ? formattedDate : "No Date"} +
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/properties/labels.tsx b/plane-src/apps/space/components/issues/issue-layouts/properties/labels.tsx new file mode 100644 index 0000000..4a6d1ab --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/properties/labels.tsx @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { LabelPropertyIcon } from "@plane/propel/icons"; +// plane imports +import { Tooltip } from "@plane/propel/tooltip"; +// hooks +import { useLabel } from "@/hooks/store/use-label"; + +type Props = { + labelIds: string[]; + shouldShowLabel?: boolean; +}; + +export const IssueBlockLabels = observer(function IssueBlockLabels({ labelIds, shouldShowLabel = false }: Props) { + const { getLabelsByIds } = useLabel(); + + const labels = getLabelsByIds(labelIds); + + const labelsString = labels.length > 0 ? labels.map((label) => label.name).join(", ") : "No Labels"; + + if (labels.length <= 0) + return ( + +
+ + {shouldShowLabel && No Labels} +
+
+ ); + + return ( +
+ {labels.length <= 2 ? ( + <> + {labels.map((label) => ( + +
+
+ +
{label?.name}
+
+
+
+ ))} + + ) : ( +
+ +
+ + {`${labels.length} Labels`} +
+
+
+ )} +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/properties/member.tsx b/plane-src/apps/space/components/issues/issue-layouts/properties/member.tsx new file mode 100644 index 0000000..c24f115 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/properties/member.tsx @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// icons +import type { LucideIcon } from "lucide-react"; +import { MembersPropertyIcon } from "@plane/propel/icons"; +// plane ui +import { Avatar, AvatarGroup } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +// +import type { TPublicMember } from "@/types/member"; + +type Props = { + memberIds: string[]; + shouldShowBorder?: boolean; +}; + +type AvatarProps = { + showTooltip: boolean; + members: TPublicMember[]; + icon?: LucideIcon; +}; + +export const ButtonAvatars = observer(function ButtonAvatars(props: AvatarProps) { + const { showTooltip, members, icon: Icon } = props; + + if (Array.isArray(members)) { + if (members.length > 1) { + return ( + + {members.map((member) => { + if (!member) return; + return ; + })} + + ); + } else if (members.length === 1) { + return ( + + ); + } + } + + return Icon ? ( + + ) : ( + + ); +}); + +export const IssueBlockMembers = observer(function IssueBlockMembers({ memberIds, shouldShowBorder = true }: Props) { + const { getMembersByIds } = useMember(); + + const members = getMembersByIds(memberIds); + + return ( +
+
+
+ + {!shouldShowBorder && members.length <= 1 && ( + {members?.[0]?.member__display_name ?? "No Assignees"} + )} +
+
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/properties/modules.tsx b/plane-src/apps/space/components/issues/issue-layouts/properties/modules.tsx new file mode 100644 index 0000000..5e80222 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/properties/modules.tsx @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane ui +import { ModuleIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +// plane utils +import { cn } from "@plane/utils"; +// hooks +import { useModule } from "@/hooks/store/use-module"; + +type Props = { + moduleIds: string[] | undefined; + shouldShowBorder?: boolean; +}; + +export const IssueBlockModules = observer(function IssueBlockModules({ moduleIds, shouldShowBorder = true }: Props) { + const { getModulesByIds } = useModule(); + + const modules = getModulesByIds(moduleIds ?? []); + + const modulesString = modules.map((module) => module.name).join(", "); + + return ( +
+ + {modules.length <= 1 ? ( +
+
+ +
{modules?.[0]?.name ?? "No Modules"}
+
+
+ ) : ( +
+
+
{modules.length} Modules
+
+
+ )} +
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/properties/priority.tsx b/plane-src/apps/space/components/issues/issue-layouts/properties/priority.tsx new file mode 100644 index 0000000..9b4dc3d --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/properties/priority.tsx @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { SignalHigh } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; +// types +import { PriorityIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { TIssuePriorities } from "@plane/types"; +// constants +import { cn, getIssuePriorityFilters } from "@plane/utils"; + +export function IssueBlockPriority({ + priority, + shouldShowName = false, +}: { + priority: TIssuePriorities | null; + shouldShowName?: boolean; +}) { + // hooks + const { t } = useTranslation(); + const priority_detail = priority != null ? getIssuePriorityFilters(priority) : null; + + const priorityClasses = { + urgent: "bg-layer-2 text-priority-urgent border-priority-urgent px-1", + high: "bg-layer-2 text-priority-high border-priority-high", + medium: "bg-layer-2 text-priority-medium border-priority-medium", + low: "bg-layer-2 text-priority-low border-priority-low", + none: "bg-layer-2 text-priority-none border-priority-none", + }; + + if (priority_detail === null) return <>; + + return ( + + + + ); +} diff --git a/plane-src/apps/space/components/issues/issue-layouts/properties/state.tsx b/plane-src/apps/space/components/issues/issue-layouts/properties/state.tsx new file mode 100644 index 0000000..de15969 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/properties/state.tsx @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane ui +import { StateGroupIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { TStateGroups } from "@plane/types"; +// plane utils +import { cn } from "@plane/utils"; +//hooks +import { useStates } from "@/hooks/store/use-state"; + +type Props = { + shouldShowBorder?: boolean; +} & ( + | { + stateDetails: { + name: string; + group: TStateGroups; + }; + } + | { + stateId: string; + } +); + +export const IssueBlockState = observer(function IssueBlockState(props: Props) { + const { shouldShowBorder = true } = props; + // store hooks + const { getStateById } = useStates(); + // derived values + const state = "stateId" in props ? getStateById(props.stateId) : props.stateDetails; + if (!state) return null; + + return ( + +
+
+ +
{state.name}
+
+
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/root.tsx b/plane-src/apps/space/components/issues/issue-layouts/root.tsx new file mode 100644 index 0000000..1b8f985 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/root.tsx @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// components +import { IssueAppliedFilters } from "@/components/issues/filters/applied-filters/root"; +import { IssuePeekOverview } from "@/components/issues/peek-overview"; +// hooks +import { useIssue } from "@/hooks/store/use-issue"; +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useIssueFilter } from "@/hooks/store/use-issue-filter"; +// store +import type { PublishStore } from "@/store/publish/publish.store"; +// local imports +import { SomethingWentWrongError } from "./error"; +import { IssueKanbanLayoutRoot } from "./kanban/base-kanban-root"; +import { IssuesListLayoutRoot } from "./list/base-list-root"; + +type Props = { + peekId: string | undefined; + publishSettings: PublishStore; +}; + +export const IssuesLayoutsRoot = observer(function IssuesLayoutsRoot(props: Props) { + const { peekId, publishSettings } = props; + // store hooks + const { getIssueFilters } = useIssueFilter(); + const { fetchPublicIssues } = useIssue(); + const issueDetailStore = useIssueDetails(); + // derived values + const { anchor } = publishSettings; + const issueFilters = anchor ? getIssueFilters(anchor) : undefined; + // derived values + const activeLayout = issueFilters?.display_filters?.layout || undefined; + + const { error } = useSWR( + anchor ? `PUBLIC_ISSUES_${anchor}` : null, + anchor + ? () => fetchPublicIssues(anchor, "init-loader", { groupedBy: "state", canGroup: true, perPageCount: 50 }) + : null, + { revalidateIfStale: false, revalidateOnFocus: false } + ); + + useEffect(() => { + if (peekId) { + issueDetailStore.setPeekId(peekId.toString()); + } + }, [peekId, issueDetailStore]); + + if (!anchor) return null; + + if (error) return ; + + return ( +
+ {peekId && } + {activeLayout && ( +
+ {/* applied filters */} + + + {activeLayout === "list" && ( +
+ +
+ )} + {activeLayout === "kanban" && ( +
+ +
+ )} +
+ )} +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/issue-layouts/utils.tsx b/plane-src/apps/space/components/issues/issue-layouts/utils.tsx new file mode 100644 index 0000000..14d61c6 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/utils.tsx @@ -0,0 +1,241 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { isNil } from "lodash-es"; +// types +import { EIconSize, ISSUE_PRIORITIES } from "@plane/constants"; +import { CycleGroupIcon, CycleIcon, ModuleIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons"; +import type { + GroupByColumnTypes, + IGroupByColumn, + TCycleGroups, + IIssueDisplayProperties, + TGroupedIssues, +} from "@plane/types"; +// ui +import { Avatar } from "@plane/ui"; +// components +// constants +// stores +import type { ICycleStore } from "@/store/cycle.store"; +import type { IIssueLabelStore } from "@/store/label.store"; +import type { IIssueMemberStore } from "@/store/members.store"; +import type { IIssueModuleStore } from "@/store/module.store"; +import type { IStateStore } from "@/store/state.store"; + +export const HIGHLIGHT_CLASS = "highlight"; +export const HIGHLIGHT_WITH_LINE = "highlight-with-line"; + +export const getGroupByColumns = ( + groupBy: GroupByColumnTypes | null, + cycle: ICycleStore, + module: IIssueModuleStore, + label: IIssueLabelStore, + projectState: IStateStore, + member: IIssueMemberStore, + includeNone?: boolean +): IGroupByColumn[] | undefined => { + switch (groupBy) { + case "cycle": + return getCycleColumns(cycle); + case "module": + return getModuleColumns(module); + case "state": + return getStateColumns(projectState); + case "priority": + return getPriorityColumns(); + case "labels": + return getLabelsColumns(label) as any; + case "assignees": + return getAssigneeColumns(member); + case "created_by": + return getCreatedByColumns(member) as any; + default: + if (includeNone) return [{ id: `All Issues`, name: `All work items`, payload: {}, icon: undefined }]; + } +}; + +const getCycleColumns = (cycleStore: ICycleStore): IGroupByColumn[] | undefined => { + const { cycles } = cycleStore; + + if (!cycles) return; + + const cycleGroups: IGroupByColumn[] = []; + + cycles.map((cycle) => { + if (cycle) { + const cycleStatus = cycle?.status ? (cycle.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + cycleGroups.push({ + id: cycle.id, + name: cycle.name, + icon: , + payload: { cycle_id: cycle.id }, + }); + } + }); + cycleGroups.push({ + id: "None", + name: "None", + icon: , + payload: { cycle_id: null }, + }); + + return cycleGroups; +}; + +const getModuleColumns = (moduleStore: IIssueModuleStore): IGroupByColumn[] | undefined => { + const { modules } = moduleStore; + + if (!modules) return; + + const moduleGroups: IGroupByColumn[] = []; + + modules.map((moduleInfo) => { + if (moduleInfo) + moduleGroups.push({ + id: moduleInfo.id, + name: moduleInfo.name, + icon: , + payload: { module_ids: [moduleInfo.id] }, + }); + }) as any; + moduleGroups.push({ + id: "None", + name: "None", + icon: , + payload: { module_ids: [] }, + }); + + return moduleGroups as any; +}; + +const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefined => { + const { sortedStates } = projectState; + if (!sortedStates) return; + + return sortedStates.map((state) => ({ + id: state.id, + name: state.name, + icon: ( +
+ +
+ ), + payload: { state_id: state.id }, + })) as any; +}; + +const getPriorityColumns = () => { + const priorities = ISSUE_PRIORITIES; + + return priorities.map((priority) => ({ + id: priority.key, + name: priority.title, + icon: , + payload: { priority: priority.key }, + })); +}; + +const getLabelsColumns = (label: IIssueLabelStore) => { + const { labels: storeLabels } = label; + + if (!storeLabels) return; + + const labels = [...storeLabels, { id: "None", name: "None", color: "#666" }]; + + return labels.map((label) => ({ + id: label.id, + name: label.name, + icon: ( +
+ ), + payload: label?.id === "None" ? {} : { label_ids: [label.id] }, + })); +}; + +const getAssigneeColumns = (member: IIssueMemberStore) => { + const { members } = member; + + if (!members) return; + + const assigneeColumns: any = members.map((member) => ({ + id: member.id, + name: member?.member__display_name || "", + icon: , + payload: { assignee_ids: [member.id] }, + })); + + assigneeColumns.push({ id: "None", name: "None", icon: , payload: {} }); + + return assigneeColumns; +}; + +const getCreatedByColumns = (member: IIssueMemberStore) => { + const { members } = member; + + if (!members) return; + + return members.map((member) => ({ + id: member.id, + name: member?.member__display_name || "", + icon: , + payload: {}, + })); +}; + +export const getDisplayPropertiesCount = ( + displayProperties: IIssueDisplayProperties, + ignoreFields?: (keyof IIssueDisplayProperties)[] +) => { + const propertyKeys = Object.keys(displayProperties) as (keyof IIssueDisplayProperties)[]; + + let count = 0; + + for (const propertyKey of propertyKeys) { + if (ignoreFields && ignoreFields.includes(propertyKey)) continue; + if (displayProperties[propertyKey]) count++; + } + + return count; +}; + +export const getIssueBlockId = (issueId: string | undefined, groupId: string | undefined, subGroupId?: string) => + `issue_${issueId}_${groupId}_${subGroupId}`; + +/** + * returns empty Array if groupId is None + * @param groupId + * @returns + */ +export const getGroupId = (groupId: string) => { + if (groupId === "None") return []; + return [groupId]; +}; + +/** + * method that removes Null or undefined Keys from object + * @param obj + * @returns + */ +export const removeNillKeys = (obj: T) => + Object.fromEntries(Object.entries(obj ?? {}).filter(([key, value]) => key && !isNil(value))); + +/** + * This Method returns if the grouped values are subGrouped + * @param groupedIssueIds + * @returns + */ +export const isSubGrouped = (groupedIssueIds: TGroupedIssues) => { + if (!groupedIssueIds || Array.isArray(groupedIssueIds)) { + return false; + } + + if (Array.isArray(groupedIssueIds[Object.keys(groupedIssueIds)[0]])) { + return false; + } + + return true; +}; diff --git a/plane-src/apps/space/components/issues/issue-layouts/with-display-properties-HOC.tsx b/plane-src/apps/space/components/issues/issue-layouts/with-display-properties-HOC.tsx new file mode 100644 index 0000000..bd6a665 --- /dev/null +++ b/plane-src/apps/space/components/issues/issue-layouts/with-display-properties-HOC.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import type { IIssueDisplayProperties } from "@plane/types"; + +interface IWithDisplayPropertiesHOC { + displayProperties: IIssueDisplayProperties; + shouldRenderProperty?: (displayProperties: IIssueDisplayProperties) => boolean; + displayPropertyKey: keyof IIssueDisplayProperties | (keyof IIssueDisplayProperties)[]; + children: React.ReactNode; +} + +export const WithDisplayPropertiesHOC = observer(function WithDisplayPropertiesHOC({ + displayProperties, + shouldRenderProperty, + displayPropertyKey, + children, +}: IWithDisplayPropertiesHOC) { + let shouldDisplayPropertyFromFilters = false; + if (Array.isArray(displayPropertyKey)) + shouldDisplayPropertyFromFilters = displayPropertyKey.every((key) => !!displayProperties[key]); + else shouldDisplayPropertyFromFilters = !!displayProperties[displayPropertyKey]; + + const renderProperty = + shouldDisplayPropertyFromFilters && (shouldRenderProperty ? shouldRenderProperty(displayProperties) : true); + + if (!renderProperty) return null; + + return <>{children}; +}); diff --git a/plane-src/apps/space/components/issues/navbar/controls.tsx b/plane-src/apps/space/components/issues/navbar/controls.tsx new file mode 100644 index 0000000..f5902dd --- /dev/null +++ b/plane-src/apps/space/components/issues/navbar/controls.tsx @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useRouter, useSearchParams } from "next/navigation"; +// components +import { IssueFiltersDropdown } from "@/components/issues/filters"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useIssueFilter } from "@/hooks/store/use-issue-filter"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; +// store +import type { PublishStore } from "@/store/publish/publish.store"; +// types +import type { TIssueLayout } from "@/types/issue"; +// local imports +import { IssuesLayoutSelection } from "./layout-selection"; +import { NavbarTheme } from "./theme"; +import { UserAvatar } from "./user-avatar"; + +export type NavbarControlsProps = { + publishSettings: PublishStore; +}; + +export const NavbarControls = observer(function NavbarControls(props: NavbarControlsProps) { + // props + const { publishSettings } = props; + // router + const router = useRouter(); + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board") || undefined; + const labels = searchParams.get("labels") || undefined; + const state = searchParams.get("state") || undefined; + const priority = searchParams.get("priority") || undefined; + const peekId = searchParams.get("peekId") || undefined; + // hooks + const { getIssueFilters, isIssueFiltersUpdated, initIssueFilters } = useIssueFilter(); + const { setPeekId } = useIssueDetails(); + // derived values + const { anchor, view_props, workspace_detail } = publishSettings; + const issueFilters = anchor ? getIssueFilters(anchor) : undefined; + const activeLayout = issueFilters?.display_filters?.layout || undefined; + + const isInIframe = useIsInIframe(); + + useEffect(() => { + if (anchor && workspace_detail) { + const viewsAcceptable: string[] = []; + let currentBoard: TIssueLayout | null = null; + + if (view_props?.list) viewsAcceptable.push("list"); + if (view_props?.kanban) viewsAcceptable.push("kanban"); + if (view_props?.calendar) viewsAcceptable.push("calendar"); + if (view_props?.gantt) viewsAcceptable.push("gantt"); + if (view_props?.spreadsheet) viewsAcceptable.push("spreadsheet"); + + if (board) { + if (viewsAcceptable.includes(board.toString())) currentBoard = board.toString() as TIssueLayout; + else { + if (viewsAcceptable && viewsAcceptable.length > 0) currentBoard = viewsAcceptable[0] as TIssueLayout; + } + } else { + if (viewsAcceptable && viewsAcceptable.length > 0) currentBoard = viewsAcceptable[0] as TIssueLayout; + } + + if (currentBoard) { + if (activeLayout === undefined || activeLayout !== currentBoard) { + const { query, queryParam } = queryParamGenerator({ board: currentBoard, peekId, priority, state, labels }); + const params: any = { + display_filters: { layout: (query?.board as string[])[0] }, + filters: { + priority: query?.priority ?? undefined, + state: query?.state ?? undefined, + labels: query?.labels ?? undefined, + }, + }; + + if (!isIssueFiltersUpdated(anchor, params)) { + initIssueFilters(anchor, params); + router.push(`/issues/${anchor}?${queryParam}`); + } + } + } + } + }, [ + anchor, + board, + labels, + state, + priority, + peekId, + activeLayout, + router, + initIssueFilters, + setPeekId, + isIssueFiltersUpdated, + view_props, + workspace_detail, + ]); + + if (!anchor) return null; + + return ( + <> + {/* issue views */} +
+ +
+ + {/* issue filters */} +
+ +
+ + {/* theming */} +
+ +
+ + {!isInIframe && } + + ); +}); diff --git a/plane-src/apps/space/components/issues/navbar/index.ts b/plane-src/apps/space/components/issues/navbar/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/plane-src/apps/space/components/issues/navbar/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./root"; diff --git a/plane-src/apps/space/components/issues/navbar/layout-icon.tsx b/plane-src/apps/space/components/issues/navbar/layout-icon.tsx new file mode 100644 index 0000000..79f8f39 --- /dev/null +++ b/plane-src/apps/space/components/issues/navbar/layout-icon.tsx @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { TIssueLayout } from "@plane/constants"; +import { ListLayoutIcon, BoardLayoutIcon } from "@plane/propel/icons"; +import type { ISvgIcons } from "@plane/propel/icons"; + +export function IssueLayoutIcon({ + layout, + size, + ...props +}: { layout: TIssueLayout; size?: number } & Omit) { + const iconProps = { + ...props, + ...(size && { width: size, height: size }), + }; + + switch (layout) { + case "list": + return ; + case "kanban": + return ; + default: + return null; + } +} diff --git a/plane-src/apps/space/components/issues/navbar/layout-selection.tsx b/plane-src/apps/space/components/issues/navbar/layout-selection.tsx new file mode 100644 index 0000000..927fd7d --- /dev/null +++ b/plane-src/apps/space/components/issues/navbar/layout-selection.tsx @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useRouter, useSearchParams } from "next/navigation"; +// ui +import { SITES_ISSUE_LAYOUTS } from "@plane/constants"; +// plane i18n +import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueFilter } from "@/hooks/store/use-issue-filter"; +// mobx +import type { TIssueLayout } from "@/types/issue"; +import { IssueLayoutIcon } from "./layout-icon"; + +type Props = { + anchor: string; +}; + +export const IssuesLayoutSelection = observer(function IssuesLayoutSelection(props: Props) { + const { anchor } = props; + // hooks + const { t } = useTranslation(); + // router + const router = useRouter(); + const searchParams = useSearchParams(); + // query params + const labels = searchParams.get("labels"); + const state = searchParams.get("state"); + const priority = searchParams.get("priority"); + const peekId = searchParams.get("peekId"); + // hooks + const { layoutOptions, getIssueFilters, updateIssueFilters } = useIssueFilter(); + // derived values + const issueFilters = getIssueFilters(anchor); + const activeLayout = issueFilters?.display_filters?.layout || undefined; + + const handleCurrentBoardView = (boardView: TIssueLayout) => { + updateIssueFilters(anchor, "display_filters", "layout", boardView); + const { queryParam } = queryParamGenerator({ board: boardView, peekId, priority, state, labels }); + router.push(`/issues/${anchor}?${queryParam}`); + }; + + return ( +
+ {SITES_ISSUE_LAYOUTS.map((layout) => { + if (!layoutOptions[layout.key]) return; + + return ( + + + + ); + })} +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/navbar/root.tsx b/plane-src/apps/space/components/issues/navbar/root.tsx new file mode 100644 index 0000000..7f5221f --- /dev/null +++ b/plane-src/apps/space/components/issues/navbar/root.tsx @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { ProjectIcon } from "@plane/propel/icons"; +// components +import { ProjectLogo } from "@/components/common/project-logo"; +// store +import type { PublishStore } from "@/store/publish/publish.store"; +// local imports +import { NavbarControls } from "./controls"; + +type Props = { + publishSettings: PublishStore; +}; + +export const IssuesNavbarRoot = observer(function IssuesNavbarRoot(props: Props) { + const { publishSettings } = props; + // hooks + const { project_details } = publishSettings; + + return ( +
+ {/* project detail */} +
+ {project_details ? ( + + + + ) : ( + + + + )} +
+ {project_details?.name || `...`} +
+
+
+ +
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/navbar/theme.tsx b/plane-src/apps/space/components/issues/navbar/theme.tsx new file mode 100644 index 0000000..63c85e3 --- /dev/null +++ b/plane-src/apps/space/components/issues/navbar/theme.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +import { Moon, Sun } from "lucide-react"; + +export const NavbarTheme = observer(function NavbarTheme() { + // states + const [appTheme, setAppTheme] = useState("light"); + // theme + const { setTheme, theme } = useTheme(); + + const handleTheme = () => { + setTheme(theme === "light" ? "dark" : "light"); + }; + + useEffect(() => { + if (!theme) return; + setAppTheme(theme); + }, [theme]); + + return ( + + ); +}); diff --git a/plane-src/apps/space/components/issues/navbar/user-avatar.tsx b/plane-src/apps/space/components/issues/navbar/user-avatar.tsx new file mode 100644 index 0000000..595fe57 --- /dev/null +++ b/plane-src/apps/space/components/issues/navbar/user-avatar.tsx @@ -0,0 +1,128 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Fragment, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { Link } from "react-router"; +import { usePathname, useSearchParams } from "next/navigation"; +import { usePopper } from "react-popper"; +import { LogOut } from "lucide-react"; +import { Popover, Transition } from "@headlessui/react"; +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { AuthService } from "@plane/services"; +import { Avatar } from "@plane/ui"; +import { getFileURL } from "@plane/utils"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useUser } from "@/hooks/store/use-user"; + +const authService = new AuthService(); + +export const UserAvatar = observer(function UserAvatar() { + const pathName = usePathname(); + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board") || undefined; + const labels = searchParams.get("labels") || undefined; + const state = searchParams.get("state") || undefined; + const priority = searchParams.get("priority") || undefined; + const peekId = searchParams.get("peekId") || undefined; + // hooks + const { data: currentUser, signOut } = useUser(); + // states + const [csrfToken, setCsrfToken] = useState(undefined); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-end", + modifiers: [ + { + name: "offset", + options: { + offset: [0, 40], + }, + }, + ], + }); + + // derived values + const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels }); + + return ( +
+ {currentUser?.id ? ( +
+ + + + + + +
+ {csrfToken && ( +
+ + + +
+ )} +
+
+
+
+
+ ) : ( +
+ + + +
+ )} +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/peek-overview/comment/add-comment.tsx b/plane-src/apps/space/components/issues/peek-overview/comment/add-comment.tsx new file mode 100644 index 0000000..acd86cd --- /dev/null +++ b/plane-src/apps/space/components/issues/peek-overview/comment/add-comment.tsx @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useForm, Controller } from "react-hook-form"; +// plane imports +import type { EditorRefApi } from "@plane/editor"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { SitesFileService } from "@plane/services"; +import type { TIssuePublicComment } from "@plane/types"; +// editor components +import { LiteTextEditor } from "@/components/editor/lite-text-editor"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useUser } from "@/hooks/store/use-user"; +// services +const fileService = new SitesFileService(); + +const defaultValues: Partial = { + comment_html: "", +}; + +type Props = { + anchor: string; + disabled?: boolean; +}; + +export const AddComment = observer(function AddComment(props: Props) { + const { anchor } = props; + // states + const [uploadedAssetIds, setUploadAssetIds] = useState([]); + // refs + const editorRef = useRef(null); + // store hooks + const { peekId: issueId, addIssueComment, uploadCommentAsset } = useIssueDetails(); + const { data: currentUser } = useUser(); + const { workspace: workspaceID } = usePublish(anchor); + // form info + const { + handleSubmit, + control, + watch, + formState: { isSubmitting }, + reset, + } = useForm({ defaultValues }); + + const onSubmit = async (formData: TIssuePublicComment) => { + if (!anchor || !issueId || isSubmitting || !formData.comment_html) return; + + await addIssueComment(anchor, issueId, formData) + .then(async (res) => { + reset(defaultValues); + editorRef.current?.clearEditor(); + if (uploadedAssetIds.length > 0) { + await fileService.updateBulkAssetsUploadStatus(anchor, res.id, { + asset_ids: uploadedAssetIds, + }); + setUploadAssetIds([]); + } + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Comment could not be posted. Please try again.", + }) + ); + }; + + // TODO: on click if he user is not logged in redirect to login page + return ( +
+
+ ( + { + if (currentUser) handleSubmit(onSubmit)(e); + }} + anchor={anchor} + workspaceId={workspaceID?.toString() ?? ""} + ref={editorRef} + id="peek-overview-add-comment" + initialValue={ + !value || value === "" || (typeof value === "object" && Object.keys(value).length === 0) + ? watch("comment_html") + : value + } + onChange={(comment_json, comment_html) => onChange(comment_html)} + isSubmitting={isSubmitting} + placeholder="Add comment..." + uploadFile={async (blockId, file) => { + const { asset_id } = await uploadCommentAsset(file, anchor); + setUploadAssetIds((prev) => [...prev, asset_id]); + return asset_id; + }} + displayConfig={{ + fontSize: "small-font", + }} + /> + )} + /> +
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/peek-overview/comment/comment-detail-card.tsx b/plane-src/apps/space/components/issues/peek-overview/comment/comment-detail-card.tsx new file mode 100644 index 0000000..27506d8 --- /dev/null +++ b/plane-src/apps/space/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -0,0 +1,228 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { MessageSquare, MoreVertical } from "lucide-react"; +import { Menu, Transition } from "@headlessui/react"; +// plane imports +import type { EditorRefApi } from "@plane/editor"; +import { CheckIcon, CloseIcon } from "@plane/propel/icons"; +import type { TIssuePublicComment } from "@plane/types"; +import { getFileURL } from "@plane/utils"; +// components +import { LiteTextEditor } from "@/components/editor/lite-text-editor"; +import { CommentReactions } from "@/components/issues/peek-overview/comment/comment-reactions"; +// helpers +import { timeAgo } from "@/helpers/date-time.helper"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useUser } from "@/hooks/store/use-user"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; + +type Props = { + anchor: string; + comment: TIssuePublicComment; +}; + +export const CommentCard = observer(function CommentCard(props: Props) { + const { anchor, comment } = props; + // store hooks + const { peekId, deleteIssueComment, updateIssueComment, uploadCommentAsset } = useIssueDetails(); + const { data: currentUser } = useUser(); + const { workspace: workspaceID } = usePublish(anchor); + const isInIframe = useIsInIframe(); + + // states + const [isEditing, setIsEditing] = useState(false); + // refs + const editorRef = useRef(null); + const showEditorRef = useRef(null); + // form info + const { + control, + formState: { isSubmitting }, + handleSubmit, + } = useForm({ + defaultValues: { comment_html: comment.comment_html }, + }); + + const handleDelete = () => { + if (!anchor || !peekId) return; + deleteIssueComment(anchor, peekId, comment.id); + }; + + const handleCommentUpdate = async (formData: TIssuePublicComment) => { + if (!anchor || !peekId) return; + updateIssueComment(anchor, peekId, comment.id, formData); + setIsEditing(false); + editorRef.current?.setEditorValue(formData.comment_html); + showEditorRef.current?.setEditorValue(formData.comment_html); + }; + + return ( +
+
+ {comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? ( + { + ) : ( +
+ {comment.actor_detail.is_bot + ? comment?.actor_detail?.first_name?.charAt(0) + : comment?.actor_detail?.display_name?.charAt(0)} +
+ )} + + + +
+
+
+
+ {comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name} +
+

+ <>commented {timeAgo(comment.created_at)} +

+
+
+
+
+ ( + onChange(comment_html)} + isSubmitting={isSubmitting} + showSubmitButton={false} + uploadFile={async (blockId, file) => { + const { asset_id } = await uploadCommentAsset(file, anchor, comment.id); + return asset_id; + }} + displayConfig={{ + fontSize: "small-font", + }} + /> + )} + /> +
+
+ + +
+
+
+ + +
+
+
+ {!isInIframe && currentUser?.id === comment?.actor_detail?.id && ( + + {}} + className="relative grid cursor-pointer place-items-center rounded-sm p-1 text-tertiary outline-none hover:bg-layer-transparent-hover" + > + + + + + + + {({ active }) => ( +
+ +
+ )} +
+ + {({ active }) => ( +
+ +
+ )} +
+
+
+
+ )} +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/peek-overview/comment/comment-reactions.tsx b/plane-src/apps/space/components/issues/peek-overview/comment/comment-reactions.tsx new file mode 100644 index 0000000..be6a6fb --- /dev/null +++ b/plane-src/apps/space/components/issues/peek-overview/comment/comment-reactions.tsx @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +// plane imports +import { stringToEmoji } from "@plane/propel/emoji-icon-picker"; +import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction"; +import type { EmojiReactionType } from "@plane/propel/emoji-reaction"; +// helpers +import { groupReactions } from "@/helpers/emoji.helper"; +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useUser } from "@/hooks/store/use-user"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; + +type Props = { + anchor: string; + commentId: string; +}; + +export const CommentReactions = observer(function CommentReactions(props: Props) { + const { anchor, commentId } = props; + // state + const [isPickerOpen, setIsPickerOpen] = useState(false); + const router = useRouter(); + const pathName = usePathname(); + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board") || undefined; + const state = searchParams.get("state") || undefined; + const priority = searchParams.get("priority") || undefined; + const labels = searchParams.get("labels") || undefined; + + // hooks + const { addCommentReaction, removeCommentReaction, details, peekId } = useIssueDetails(); + const { data: user } = useUser(); + const isInIframe = useIsInIframe(); + + const commentReactions = useMemo(() => { + if (!peekId) return []; + const peekDetails = details[peekId]; + if (!peekDetails) return []; + const comment = peekDetails.comments?.find((c) => c.id === commentId); + return comment?.comment_reactions ?? []; + }, [peekId, details, commentId]); + + const groupedReactions = useMemo(() => { + if (!peekId) return {}; + return groupReactions(commentReactions ?? [], "reaction"); + }, [peekId, commentReactions]); + + const userReactions = commentReactions?.filter((r) => r?.actor_detail?.id === user?.id); + + const handleAddReaction = (reactionHex: string) => { + if (!anchor || !peekId) return; + addCommentReaction(anchor, peekId, commentId, reactionHex); + }; + + const handleRemoveReaction = (reactionHex: string) => { + if (!anchor || !peekId) return; + removeCommentReaction(anchor, peekId, commentId, reactionHex); + }; + + const handleReactionClick = (reactionHex: string) => { + const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex); + + if (userReaction) handleRemoveReaction(reactionHex); + else handleAddReaction(reactionHex); + }; + + // derived values + const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels }); + + // Transform reactions data to Propel EmojiReactionType format + const propelReactions: EmojiReactionType[] = useMemo(() => { + const REACTIONS_LIMIT = 1000; + + return Object.keys(groupedReactions || {}) + .filter((reaction) => groupedReactions?.[reaction]?.length > 0) + .map((reaction) => { + const reactionList = groupedReactions?.[reaction] ?? []; + const userNames = reactionList + .map((r) => r?.actor_detail?.display_name) + .filter((name): name is string => !!name) + .slice(0, REACTIONS_LIMIT); + + return { + emoji: stringToEmoji(reaction), + count: reactionList.length, + reacted: commentReactions?.some((r) => r?.actor_detail?.id === user?.id && r.reaction === reaction) || false, + users: userNames, + }; + }); + }, [groupedReactions, commentReactions, user?.id]); + + const handleEmojiClick = (emoji: string) => { + if (isInIframe) return; + if (!user) { + router.push(`/?next_path=${pathName}?${queryParam}`); + return; + } + // Convert emoji back to decimal string format for the API + const emojiCodePoints = Array.from(emoji) + .map((char) => char.codePointAt(0)) + .filter((cp): cp is number => cp !== undefined); + const reactionString = emojiCodePoints.join("-"); + handleReactionClick(reactionString); + }; + + const handleEmojiSelect = (emoji: string) => { + if (!user) { + router.push(`/?next_path=${pathName}?${queryParam}`); + return; + } + // emoji is already in decimal string format from EmojiReactionPicker + handleReactionClick(emoji); + }; + + return ( +
+ setIsPickerOpen(true)} + /> + } + placement="bottom-start" + /> +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/peek-overview/full-screen-peek-view.tsx b/plane-src/apps/space/components/issues/peek-overview/full-screen-peek-view.tsx new file mode 100644 index 0000000..4cf425b --- /dev/null +++ b/plane-src/apps/space/components/issues/peek-overview/full-screen-peek-view.tsx @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { Loader } from "@plane/ui"; +// types +import type { IIssue } from "@/types/issue"; +// local imports +import { PeekOverviewHeader } from "./header"; +import { PeekOverviewIssueActivity } from "./issue-activity"; +import { PeekOverviewIssueDetails } from "./issue-details"; +import { PeekOverviewIssueProperties } from "./issue-properties"; + +type Props = { + anchor: string; + handleClose: () => void; + issueDetails: IIssue | undefined; +}; + +export const FullScreenPeekView = observer(function FullScreenPeekView(props: Props) { + const { anchor, handleClose, issueDetails } = props; + + return ( +
+
+
+ +
+ {issueDetails ? ( +
+ {/* issue title and description */} +
+ +
+ {/* divider */} +
+ {/* issue activity/comments */} +
+ +
+
+ ) : ( + + +
+ + + +
+
+ )} +
+
+ {/* issue properties */} +
+ {issueDetails ? ( + + ) : ( + + + + + + + )} +
+
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/peek-overview/header.tsx b/plane-src/apps/space/components/issues/peek-overview/header.tsx new file mode 100644 index 0000000..764ac4e --- /dev/null +++ b/plane-src/apps/space/components/issues/peek-overview/header.tsx @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +import { observer } from "mobx-react"; +import { MoveRight } from "lucide-react"; +import { Listbox, Transition } from "@headlessui/react"; +// ui +import { LinkIcon, CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +// helpers +import { copyTextToClipboard } from "@/helpers/string.helper"; +// hooks +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import useClipboardWritePermission from "@/hooks/use-clipboard-write-permission"; +// types +import type { IIssue, IPeekMode } from "@/types/issue"; + +type Props = { + handleClose: () => void; + issueDetails: IIssue | undefined; +}; + +const PEEK_MODES: { + key: IPeekMode; + icon: any; + label: string; +}[] = [ + { key: "side", icon: SidePanelIcon, label: "Side Peek" }, + { + key: "modal", + icon: CenterPanelIcon, + label: "Modal", + }, + { + key: "full", + icon: FullScreenPanelIcon, + label: "Full Screen", + }, +]; + +export const PeekOverviewHeader = observer(function PeekOverviewHeader(props: Props) { + const { handleClose } = props; + + const { peekMode, setPeekMode } = useIssueDetails(); + const isClipboardWriteAllowed = useClipboardWritePermission(); + + const handleCopyLink = () => { + const urlToCopy = window.location.href; + + copyTextToClipboard(urlToCopy).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Link copied!", + message: "Work item link copied to clipboard.", + }); + }); + }; + + const Icon = PEEK_MODES.find((m) => m.key === peekMode)?.icon ?? SidePanelIcon; + + return ( + <> +
+
+ {peekMode === "side" && ( + + )} + setPeekMode(val)} + className="relative shrink-0 text-left" + > + + + + + + +
+ {PEEK_MODES.map((mode) => ( + + `cursor-pointer truncate rounded-sm px-1 py-1.5 select-none ${ + active ? "bg-layer-transparent-hover" : "" + } ${selected ? "text-primary" : "text-secondary"}` + } + > +
+ + {mode.label} +
+
+ ))} +
+
+
+
+
+ {isClipboardWriteAllowed && (peekMode === "side" || peekMode === "modal") && ( + + )} +
+ + ); +}); diff --git a/plane-src/apps/space/components/issues/peek-overview/index.ts b/plane-src/apps/space/components/issues/peek-overview/index.ts new file mode 100644 index 0000000..d0bf324 --- /dev/null +++ b/plane-src/apps/space/components/issues/peek-overview/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./layout"; diff --git a/plane-src/apps/space/components/issues/peek-overview/issue-activity.tsx b/plane-src/apps/space/components/issues/peek-overview/issue-activity.tsx new file mode 100644 index 0000000..4e725c2 --- /dev/null +++ b/plane-src/apps/space/components/issues/peek-overview/issue-activity.tsx @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { Link } from "react-router"; +import { usePathname } from "next/navigation"; +import { Lock } from "lucide-react"; +// plane imports +import { Button } from "@plane/propel/button"; +// components +import { AddComment } from "@/components/issues/peek-overview/comment/add-comment"; +import { CommentCard } from "@/components/issues/peek-overview/comment/comment-detail-card"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useUser } from "@/hooks/store/use-user"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; +// types +import type { IIssue } from "@/types/issue"; + +type Props = { + anchor: string; + issueDetails: IIssue; +}; + +export const PeekOverviewIssueActivity = observer(function PeekOverviewIssueActivity(props: Props) { + const { anchor } = props; + // router + const pathname = usePathname(); + // store hooks + const { details, peekId } = useIssueDetails(); + const { data: currentUser } = useUser(); + const { canComment } = usePublish(anchor); + // derived values + const comments = details[peekId || ""]?.comments || []; + const isInIframe = useIsInIframe(); + + return ( +
+

Comments

+
+
+ {comments.map((comment) => ( + + ))} +
+ {!isInIframe && + (currentUser ? ( + <> + {canComment && ( +
+ +
+ )} + + ) : ( +
+

+ + Sign in to add your comment +

+ + + +
+ ))} +
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/peek-overview/issue-details.tsx b/plane-src/apps/space/components/issues/peek-overview/issue-details.tsx new file mode 100644 index 0000000..51139b4 --- /dev/null +++ b/plane-src/apps/space/components/issues/peek-overview/issue-details.tsx @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { RichTextEditor } from "@/components/editor/rich-text-editor"; +import { usePublish } from "@/hooks/store/publish"; +// types +import type { IIssue } from "@/types/issue"; +// local imports +import { IssueReactions } from "./issue-reaction"; + +type Props = { + anchor: string; + issueDetails: IIssue; +}; + +export const PeekOverviewIssueDetails = observer(function PeekOverviewIssueDetails(props: Props) { + const { anchor, issueDetails } = props; + // store hooks + const { project_details, workspace: workspaceID } = usePublish(anchor); + // derived values + const description = issueDetails.description_html; + + return ( +
+
+ {project_details?.identifier}-{issueDetails?.sequence_id} +
+

{issueDetails.name}

+ {description && description !== "" && description !== "

" && ( + + )} + +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/peek-overview/issue-properties.tsx b/plane-src/apps/space/components/issues/peek-overview/issue-properties.tsx new file mode 100644 index 0000000..1c010dd --- /dev/null +++ b/plane-src/apps/space/components/issues/peek-overview/issue-properties.tsx @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { LinkIcon } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { + StatePropertyIcon, + StateGroupIcon, + PriorityPropertyIcon, + DueDatePropertyIcon, + PriorityIcon, +} from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { cn, getIssuePriorityFilters } from "@plane/utils"; +// helpers +import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; +import { copyTextToClipboard, addSpaceIfCamelCase } from "@/helpers/string.helper"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import { useStates } from "@/hooks/store/use-state"; +// types +import type { IIssue, IPeekMode } from "@/types/issue"; + +type Props = { + issueDetails: IIssue; + mode?: IPeekMode; +}; + +export const PeekOverviewIssueProperties = observer(function PeekOverviewIssueProperties({ + issueDetails, + mode, +}: Props) { + // hooks + const { t } = useTranslation(); + const { getStateById } = useStates(); + const state = getStateById(issueDetails?.state_id ?? undefined); + + const { anchor } = useParams(); + + const { project_details } = usePublish(anchor?.toString()); + + const priority = issueDetails.priority ? getIssuePriorityFilters(issueDetails.priority) : null; + + const handleCopyLink = () => { + const urlToCopy = window.location.href; + + copyTextToClipboard(urlToCopy).then(() => { + setToast({ + type: TOAST_TYPE.INFO, + title: "Link copied!", + message: "Work item link copied to clipboard", + }); + }); + }; + + return ( +
+ {mode === "full" && ( +
+
+ {project_details?.identifier}-{issueDetails.sequence_id} +
+
+ +
+
+ )} +
+
+
+ + State +
+
+ + {addSpaceIfCamelCase(state?.name ?? "")} +
+
+ +
+
+ + Priority +
+
+
+ {priority && } + {t(priority?.titleTranslationKey || "common.none")} +
+
+
+ +
+
+ + Due date +
+
+ {issueDetails.target_date ? ( +
+ + {renderFormattedDate(issueDetails.target_date)} +
+ ) : ( + Empty + )} +
+
+
+
+ ); +}); diff --git a/plane-src/apps/space/components/issues/peek-overview/issue-reaction.tsx b/plane-src/apps/space/components/issues/peek-overview/issue-reaction.tsx new file mode 100644 index 0000000..2b847d4 --- /dev/null +++ b/plane-src/apps/space/components/issues/peek-overview/issue-reaction.tsx @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// components +import { IssueEmojiReactions } from "@/components/issues/reactions/issue-emoji-reactions"; +import { IssueVotes } from "@/components/issues/reactions/issue-vote-reactions"; +// hooks +import { usePublish } from "@/hooks/store/publish"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; + +type Props = { + anchor: string; +}; + +export const IssueReactions = observer(function IssueReactions(props: Props) { + const { anchor } = props; + // store hooks + const { canVote, canReact } = usePublish(anchor); + const isInIframe = useIsInIframe(); + + return ( +
+ {canVote && ( +
+ +
+ )} + {!isInIframe && canReact && ( +
+ +
+ )} +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/peek-overview/layout.tsx b/plane-src/apps/space/components/issues/peek-overview/layout.tsx new file mode 100644 index 0000000..ebde720 --- /dev/null +++ b/plane-src/apps/space/components/issues/peek-overview/layout.tsx @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Fragment, useEffect } from "react"; +import { observer } from "mobx-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Dialog, Transition } from "@headlessui/react"; +// hooks +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +// local imports +import { FullScreenPeekView } from "./full-screen-peek-view"; +import { SidePeekView } from "./side-peek-view"; + +type TIssuePeekOverview = { + anchor: string; + peekId: string; + handlePeekClose?: () => void; +}; + +export const IssuePeekOverview = observer(function IssuePeekOverview(props: TIssuePeekOverview) { + const { anchor, peekId, handlePeekClose } = props; + const router = useRouter(); + const searchParams = useSearchParams(); + // query params + const board = searchParams.get("board") || undefined; + const state = searchParams.get("state") || undefined; + const priority = searchParams.get("priority") || undefined; + const labels = searchParams.get("labels") || undefined; + // store + const { peekMode, setPeekId, getIssueById, fetchIssueDetails } = useIssueDetails(); + // derived values + const issueDetails = peekId ? getIssueById(peekId.toString()) : undefined; + // state + const isSidePeekOpen = !!peekId && peekMode === "side"; + const isModalPeekOpen = !!peekId && (peekMode === "modal" || peekMode === "full"); + + useEffect(() => { + if (anchor && peekId) { + fetchIssueDetails(anchor, peekId.toString()); + } + }, [anchor, fetchIssueDetails, peekId]); + + const handleClose = () => { + // if close logic is passed down, call that instead of the below logic + if (handlePeekClose) { + handlePeekClose(); + return; + } + + setPeekId(null); + let queryParams: any = { + board, + }; + if (priority && priority.length > 0) queryParams = { ...queryParams, priority: priority }; + if (state && state.length > 0) queryParams = { ...queryParams, state: state }; + if (labels && labels.length > 0) queryParams = { ...queryParams, labels: labels }; + queryParams = new URLSearchParams(queryParams).toString(); + router.push(`/issues/${anchor}?${queryParams}`); + }; + + return ( + <> + + + + + + + + + + + + +
+ + + +
+ {peekMode === "modal" && ( + + )} + {peekMode === "full" && ( + + )} +
+
+
+
+
+ + ); +}); diff --git a/plane-src/apps/space/components/issues/peek-overview/side-peek-view.tsx b/plane-src/apps/space/components/issues/peek-overview/side-peek-view.tsx new file mode 100644 index 0000000..aa981d8 --- /dev/null +++ b/plane-src/apps/space/components/issues/peek-overview/side-peek-view.tsx @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { Loader } from "@plane/ui"; +// store hooks +import { usePublish } from "@/hooks/store/publish"; +// types +import type { IIssue } from "@/types/issue"; +// local imports +import { PeekOverviewHeader } from "./header"; +import { PeekOverviewIssueActivity } from "./issue-activity"; +import { PeekOverviewIssueDetails } from "./issue-details"; +import { PeekOverviewIssueProperties } from "./issue-properties"; + +type Props = { + anchor: string; + handleClose: () => void; + issueDetails: IIssue | undefined; +}; + +export const SidePeekView = observer(function SidePeekView(props: Props) { + const { anchor, handleClose, issueDetails } = props; + // store hooks + const { canComment } = usePublish(anchor); + + return ( +
+
+ +
+ {issueDetails ? ( +
+ {/* issue title and description */} +
+ +
+ {/* issue properties */} +
+ +
+ {/* divider */} +
+ {/* issue activity/comments */} + {canComment && ( +
+ +
+ )} +
+ ) : ( + + +
+ + + +
+
+ )} +
+ ); +}); diff --git a/plane-src/apps/space/components/issues/reactions/issue-emoji-reactions.tsx b/plane-src/apps/space/components/issues/reactions/issue-emoji-reactions.tsx new file mode 100644 index 0000000..7727ec9 --- /dev/null +++ b/plane-src/apps/space/components/issues/reactions/issue-emoji-reactions.tsx @@ -0,0 +1,127 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +// lib +import { stringToEmoji } from "@plane/propel/emoji-icon-picker"; +import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction"; +import type { EmojiReactionType } from "@plane/propel/emoji-reaction"; +// helpers +import { groupReactions } from "@/helpers/emoji.helper"; +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useUser } from "@/hooks/store/use-user"; + +type IssueEmojiReactionsProps = { + anchor: string; + issueIdFromProps?: string; +}; + +export const IssueEmojiReactions = observer(function IssueEmojiReactions(props: IssueEmojiReactionsProps) { + const { anchor, issueIdFromProps } = props; + // state + const [isPickerOpen, setIsPickerOpen] = useState(false); + // router + const router = useRouter(); + const pathName = usePathname(); + const searchParams = useSearchParams(); + // query params + const peekId = searchParams.get("peekId") || undefined; + const board = searchParams.get("board") || undefined; + const state = searchParams.get("state") || undefined; + const priority = searchParams.get("priority") || undefined; + const labels = searchParams.get("labels") || undefined; + // store hooks + const issueDetailsStore = useIssueDetails(); + const { data: user } = useUser(); + + const issueId = issueIdFromProps ?? issueDetailsStore.peekId; + const reactions = issueDetailsStore.details[issueId ?? ""]?.reaction_items ?? []; + const groupedReactions = groupReactions(reactions, "reaction"); + + const userReactions = reactions.filter((r) => r.actor_details?.id === user?.id); + + const handleAddReaction = (reactionHex: string) => { + if (!issueId) return; + issueDetailsStore.addIssueReaction(anchor, issueId, reactionHex); + }; + + const handleRemoveReaction = (reactionHex: string) => { + if (!issueId) return; + issueDetailsStore.removeIssueReaction(anchor, issueId, reactionHex); + }; + + const handleReactionClick = (reactionHex: string) => { + const userReaction = userReactions?.find((r) => r.actor_details?.id === user?.id && r.reaction === reactionHex); + if (userReaction) handleRemoveReaction(reactionHex); + else handleAddReaction(reactionHex); + }; + + // derived values + const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels }); + + // Transform reactions data to Propel EmojiReactionType format + const propelReactions: EmojiReactionType[] = useMemo(() => { + const REACTIONS_LIMIT = 1000; + + return Object.keys(groupedReactions || {}) + .filter((reaction) => groupedReactions?.[reaction]?.length > 0) + .map((reaction) => { + const reactionList = groupedReactions?.[reaction] ?? []; + const userNames = reactionList + .map((r) => r?.actor_details?.display_name) + .filter((name): name is string => !!name) + .slice(0, REACTIONS_LIMIT); + + return { + emoji: stringToEmoji(reaction), + count: reactionList.length, + reacted: reactionList.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction), + users: userNames, + }; + }); + }, [groupedReactions, user?.id]); + + const handleEmojiClick = (emoji: string) => { + if (!user) { + router.push(`/?next_path=${pathName}?${queryParam}`); + return; + } + // Convert emoji back to decimal string format for the API + const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0)); + const reactionString = emojiCodePoints.join("-"); + handleReactionClick(reactionString); + }; + + const handleEmojiSelect = (emoji: string) => { + if (!user) { + router.push(`/?next_path=${pathName}?${queryParam}`); + return; + } + // emoji is already in decimal string format from EmojiReactionPicker + handleReactionClick(emoji); + }; + + return ( + setIsPickerOpen(true)} + /> + } + placement="bottom-start" + /> + ); +}); diff --git a/plane-src/apps/space/components/issues/reactions/issue-vote-reactions.tsx b/plane-src/apps/space/components/issues/reactions/issue-vote-reactions.tsx new file mode 100644 index 0000000..c416093 --- /dev/null +++ b/plane-src/apps/space/components/issues/reactions/issue-vote-reactions.tsx @@ -0,0 +1,165 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { ArrowDown, ArrowUp } from "lucide-react"; +import { observer } from "mobx-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +// plane imports +import { Tooltip } from "@plane/propel/tooltip"; +import { cn } from "@plane/utils"; +// helpers +import { queryParamGenerator } from "@/helpers/query-param-generator"; +// hooks +import { useIssueDetails } from "@/hooks/store/use-issue-details"; +import { useUser } from "@/hooks/store/use-user"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; + +type TIssueVotes = { + anchor: string; + issueIdFromProps?: string; + size?: "md" | "sm"; +}; + +export const IssueVotes = observer(function IssueVotes(props: TIssueVotes) { + const { anchor, issueIdFromProps, size = "md" } = props; + // states + const [isSubmitting, setIsSubmitting] = useState(false); + // router + const router = useRouter(); + const pathName = usePathname(); + const searchParams = useSearchParams(); + // query params + const peekId = searchParams.get("peekId") || undefined; + const board = searchParams.get("board") || undefined; + const state = searchParams.get("state") || undefined; + const priority = searchParams.get("priority") || undefined; + const labels = searchParams.get("labels") || undefined; + // store hooks + const issueDetailsStore = useIssueDetails(); + const { data: user } = useUser(); + + const isInIframe = useIsInIframe(); + + const issueId = issueIdFromProps ?? issueDetailsStore.peekId; + + const votes = issueDetailsStore.details[issueId ?? ""]?.vote_items ?? []; + + const allUpVotes = votes.filter((vote) => vote.vote === 1); + const allDownVotes = votes.filter((vote) => vote.vote === -1); + + const isUpVotedByUser = allUpVotes.some((vote) => vote.actor_details?.id === user?.id); + const isDownVotedByUser = allDownVotes.some((vote) => vote.actor_details?.id === user?.id); + + const handleVote = async (e: any, voteValue: 1 | -1) => { + if (!issueId) return; + + setIsSubmitting(true); + + const actionPerformed = votes?.find((vote) => vote.actor_details?.id === user?.id && vote.vote === voteValue); + + if (actionPerformed) await issueDetailsStore.removeIssueVote(anchor, issueId); + else { + await issueDetailsStore.addIssueVote(anchor, issueId, { + vote: voteValue, + }); + } + + setIsSubmitting(false); + }; + + const VOTES_LIMIT = 1000; + + // derived values + const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels }); + const votingDimensions = size === "sm" ? "px-1 h-6 min-w-9" : "px-2 h-7"; + + return ( +
+ {/* upvote button 👇 */} + + {allUpVotes.length > 0 ? ( + <> + {allUpVotes + .map((r) => r.actor_details?.display_name) + .splice(0, VOTES_LIMIT) + .join(", ")} + {allUpVotes.length > VOTES_LIMIT && " and " + (allUpVotes.length - VOTES_LIMIT) + " more"} + + ) : ( + "No upvotes yet" + )} +
+ } + > + + + + {/* downvote button 👇 */} + + {allDownVotes.length > 0 ? ( + <> + {allDownVotes + .map((r) => r.actor_details.display_name) + .splice(0, VOTES_LIMIT) + .join(", ")} + {allDownVotes.length > VOTES_LIMIT && " and " + (allDownVotes.length - VOTES_LIMIT) + " more"} + + ) : ( + "No downvotes yet" + )} +
+ } + > + + +
+ ); +}); diff --git a/plane-src/apps/space/components/ui/not-found.tsx b/plane-src/apps/space/components/ui/not-found.tsx new file mode 100644 index 0000000..e3952f5 --- /dev/null +++ b/plane-src/apps/space/components/ui/not-found.tsx @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// images +import Image404 from "@/app/assets/404.svg?url"; + +export function PageNotFound() { + return ( +
+
+
+
+ 404- Page not found +
+
+

Oops! Something went wrong.

+

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

+
+
+
+
+ ); +} diff --git a/plane-src/apps/space/components/views/auth.tsx b/plane-src/apps/space/components/views/auth.tsx new file mode 100644 index 0000000..11591ea --- /dev/null +++ b/plane-src/apps/space/components/views/auth.tsx @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import { AuthRoot } from "@/components/account/auth-forms"; +import { PoweredBy } from "@/components/common/powered-by"; +// local imports +import { AuthHeader } from "./header"; + +export function AuthView() { + return ( +
+ + + +
+ ); +} diff --git a/plane-src/apps/space/components/views/header.tsx b/plane-src/apps/space/components/views/header.tsx new file mode 100644 index 0000000..dc2dabe --- /dev/null +++ b/plane-src/apps/space/components/views/header.tsx @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +import { Link } from "react-router"; +import { PlaneLockup } from "@plane/propel/icons"; + +export function AuthHeader() { + return ( +
+ + + +
+ ); +} diff --git a/plane-src/apps/space/components/views/index.ts b/plane-src/apps/space/components/views/index.ts new file mode 100644 index 0000000..68908cd --- /dev/null +++ b/plane-src/apps/space/components/views/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./auth"; diff --git a/plane-src/apps/space/helpers/authentication.helper.tsx b/plane-src/apps/space/helpers/authentication.helper.tsx new file mode 100644 index 0000000..e88b13f --- /dev/null +++ b/plane-src/apps/space/helpers/authentication.helper.tsx @@ -0,0 +1,406 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Link } from "react-router"; +// helpers +import { SUPPORT_EMAIL } from "@plane/constants"; + +export enum EPageTypes { + INIT = "INIT", + PUBLIC = "PUBLIC", + NON_AUTHENTICATED = "NON_AUTHENTICATED", + ONBOARDING = "ONBOARDING", + AUTHENTICATED = "AUTHENTICATED", +} + +export enum EErrorAlertType { + BANNER_ALERT = "BANNER_ALERT", + TOAST_ALERT = "TOAST_ALERT", + INLINE_FIRST_NAME = "INLINE_FIRST_NAME", + INLINE_EMAIL = "INLINE_EMAIL", + INLINE_PASSWORD = "INLINE_PASSWORD", + INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", +} + +export enum EAuthenticationErrorCodes { + // Global + INSTANCE_NOT_CONFIGURED = "5000", + INVALID_EMAIL = "5005", + EMAIL_REQUIRED = "5010", + SIGNUP_DISABLED = "5015", + // Password strength + INVALID_PASSWORD = "5020", + PASSWORD_TOO_WEAK = "5021", + SMTP_NOT_CONFIGURED = "5025", + // Sign Up + USER_ALREADY_EXIST = "5030", + AUTHENTICATION_FAILED_SIGN_UP = "5035", + REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040", + INVALID_EMAIL_SIGN_UP = "5045", + INVALID_EMAIL_MAGIC_SIGN_UP = "5050", + MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055", + // Sign In + USER_ACCOUNT_DEACTIVATED = "5019", + USER_DOES_NOT_EXIST = "5060", + AUTHENTICATION_FAILED_SIGN_IN = "5065", + REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070", + INVALID_EMAIL_SIGN_IN = "5075", + INVALID_EMAIL_MAGIC_SIGN_IN = "5080", + MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085", + // Both Sign in and Sign up for magic + INVALID_MAGIC_CODE_SIGN_IN = "5090", + INVALID_MAGIC_CODE_SIGN_UP = "5092", + EXPIRED_MAGIC_CODE_SIGN_IN = "5095", + EXPIRED_MAGIC_CODE_SIGN_UP = "5097", + EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN = "5100", + EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP = "5102", + // Oauth + OAUTH_NOT_CONFIGURED = "5104", + GOOGLE_NOT_CONFIGURED = "5105", + GITHUB_NOT_CONFIGURED = "5110", + GITLAB_NOT_CONFIGURED = "5111", + GOOGLE_OAUTH_PROVIDER_ERROR = "5115", + GITHUB_OAUTH_PROVIDER_ERROR = "5120", + GITLAB_OAUTH_PROVIDER_ERROR = "5121", + // Reset Password + INVALID_PASSWORD_TOKEN = "5125", + EXPIRED_PASSWORD_TOKEN = "5130", + // Change password + INCORRECT_OLD_PASSWORD = "5135", + MISSING_PASSWORD = "5138", + INVALID_NEW_PASSWORD = "5140", + // set password + PASSWORD_ALREADY_SET = "5145", + // Admin + ADMIN_ALREADY_EXIST = "5150", + REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155", + INVALID_ADMIN_EMAIL = "5160", + INVALID_ADMIN_PASSWORD = "5165", + REQUIRED_ADMIN_EMAIL_PASSWORD = "5170", + ADMIN_AUTHENTICATION_FAILED = "5175", + ADMIN_USER_ALREADY_EXIST = "5180", + ADMIN_USER_DOES_NOT_EXIST = "5185", +} + +export type TAuthErrorInfo = { + type: EErrorAlertType; + code: EAuthenticationErrorCodes; + title: string; + message: React.ReactNode; +}; + +const errorCodeMessages: { + [key in EAuthenticationErrorCodes]: { title: string; message: (email?: string) => React.ReactNode }; +} = { + // global + [EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED]: { + title: `Instance not configured`, + message: () => `Instance not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.SIGNUP_DISABLED]: { + title: `Sign up disabled`, + message: () => `Sign up disabled. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.INVALID_PASSWORD]: { + title: `Invalid password`, + message: () => `Invalid password. Please try again.`, + }, + [EAuthenticationErrorCodes.PASSWORD_TOO_WEAK]: { + title: `Password too weak`, + message: () => + `Password must include upper-case, lower-case, number, special character, and must not be predictable.`, + }, + [EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED]: { + title: `SMTP not configured`, + message: () => `SMTP not configured. Please contact your administrator.`, + }, + + // email check in both sign up and sign in + [EAuthenticationErrorCodes.INVALID_EMAIL]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthenticationErrorCodes.EMAIL_REQUIRED]: { + title: `Email required`, + message: () => `Email required. Please try again.`, + }, + + // sign up + [EAuthenticationErrorCodes.USER_ALREADY_EXIST]: { + title: `User already exists`, + message: (email = undefined) => ( +
+ Your account is already registered.  + + Sign In + +  now. +
+ ), + }, + [EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED]: { + title: `Email and code required`, + message: () => `Email and code required. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + + // sign in + [EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: { + title: `User account deactivated`, + message: () => `User account deactivated. Please contact ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`, + }, + + [EAuthenticationErrorCodes.USER_DOES_NOT_EXIST]: { + title: `User does not exist`, + message: (email = undefined) => ( +
+ No account found.  + + Create one + +  to get started. +
+ ), + }, + [EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED]: { + title: `Email and code required`, + message: () => `Email and code required. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + + // Both Sign in and Sign up + [EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN]: { + title: `Authentication failed`, + message: () => `Invalid magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP]: { + title: `Authentication failed`, + message: () => `Invalid magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + [EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + + // Oauth + [EAuthenticationErrorCodes.OAUTH_NOT_CONFIGURED]: { + title: `OAuth not configured`, + message: () => `OAuth not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: { + title: `Google not configured`, + message: () => `Google not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED]: { + title: `GitHub not configured`, + message: () => `GitHub not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.GITLAB_NOT_CONFIGURED]: { + title: `GitLab not configured`, + message: () => `GitLab not configured. Please contact your administrator.`, + }, + [EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: { + title: `Google OAuth provider error`, + message: () => `Google OAuth provider error. Please try again.`, + }, + [EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR]: { + title: `GitHub OAuth provider error`, + message: () => `GitHub OAuth provider error. Please try again.`, + }, + [EAuthenticationErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR]: { + title: `GitLab OAuth provider error`, + message: () => `GitLab OAuth provider error. Please try again.`, + }, + + // Reset Password + [EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: { + title: `Invalid password token`, + message: () => `Invalid password token. Please try again.`, + }, + [EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN]: { + title: `Expired password token`, + message: () => `Expired password token. Please try again.`, + }, + + // Change password + [EAuthenticationErrorCodes.MISSING_PASSWORD]: { + title: `Password required`, + message: () => `Password required. Please try again.`, + }, + [EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD]: { + title: `Incorrect old password`, + message: () => `Incorrect old password. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_NEW_PASSWORD]: { + title: `Invalid new password`, + message: () => `Invalid new password. Please try again.`, + }, + + // set password + [EAuthenticationErrorCodes.PASSWORD_ALREADY_SET]: { + title: `Password already set`, + message: () => `Password already set. Please try again.`, + }, + + // admin + [EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: { + title: `Admin already exists`, + message: () => `Admin already exists. Please try again.`, + }, + [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { + title: `Email, password and first name required`, + message: () => `Email, password and first name required. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: { + title: `Invalid admin email`, + message: () => `Invalid admin email. Please try again.`, + }, + [EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: { + title: `Invalid admin password`, + message: () => `Invalid admin password. Please try again.`, + }, + [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: { + title: `Admin user already exists`, + message: () => ( +
+ Admin user already exists.  + + Sign In + +  now. +
+ ), + }, + [EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { + title: `Admin user does not exist`, + message: () => ( +
+ Admin user does not exist.  + + Sign In + +  now. +
+ ), + }, +}; + +export const authErrorHandler = (errorCode: EAuthenticationErrorCodes, email?: string): TAuthErrorInfo | undefined => { + const bannerAlertErrorCodes = [ + EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED, + EAuthenticationErrorCodes.INVALID_EMAIL, + EAuthenticationErrorCodes.EMAIL_REQUIRED, + EAuthenticationErrorCodes.SIGNUP_DISABLED, + EAuthenticationErrorCodes.INVALID_PASSWORD, + EAuthenticationErrorCodes.PASSWORD_TOO_WEAK, + EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED, + EAuthenticationErrorCodes.USER_ALREADY_EXIST, + EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP, + EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP, + EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP, + EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED, + EAuthenticationErrorCodes.USER_DOES_NOT_EXIST, + EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN, + EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN, + EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN, + EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED, + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN, + EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP, + EAuthenticationErrorCodes.OAUTH_NOT_CONFIGURED, + EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED, + EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED, + EAuthenticationErrorCodes.GITLAB_NOT_CONFIGURED, + EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR, + EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR, + EAuthenticationErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR, + EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN, + EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN, + EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD, + EAuthenticationErrorCodes.INVALID_NEW_PASSWORD, + EAuthenticationErrorCodes.PASSWORD_ALREADY_SET, + EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST, + EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, + EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL, + EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD, + EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD, + EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED, + EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST, + EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, + EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED, + ]; + + if (bannerAlertErrorCodes.includes(errorCode)) + return { + type: EErrorAlertType.BANNER_ALERT, + code: errorCode, + title: errorCodeMessages[errorCode]?.title || "Error", + message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", + }; + + return undefined; +}; diff --git a/plane-src/apps/space/helpers/common.helper.ts b/plane-src/apps/space/helpers/common.helper.ts new file mode 100644 index 0000000..f11f634 --- /dev/null +++ b/plane-src/apps/space/helpers/common.helper.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export const resolveGeneralTheme = (resolvedTheme: string | undefined) => + resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system"; diff --git a/plane-src/apps/space/helpers/date-time.helper.ts b/plane-src/apps/space/helpers/date-time.helper.ts new file mode 100644 index 0000000..eb6ce9b --- /dev/null +++ b/plane-src/apps/space/helpers/date-time.helper.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { format, isValid } from "date-fns"; +import { isNumber } from "lodash-es"; + +export const timeAgo = (time: any) => { + switch (typeof time) { + case "number": + break; + case "string": + time = +new Date(time); + break; + case "object": + if (time.constructor === Date) time = time.getTime(); + break; + default: + time = +new Date(); + } +}; + +/** + * This method returns a date from string of type yyyy-mm-dd + * This method is recommended to use instead of new Date() as this does not introduce any timezone offsets + * @param date + * @returns date or undefined + */ +export const getDate = (date: string | Date | undefined | null): Date | undefined => { + try { + if (!date || date === "") return; + + if (typeof date !== "string" && !(date instanceof String)) return date; + + const [yearString, monthString, dayString] = date.substring(0, 10).split("-"); + const year = parseInt(yearString); + const month = parseInt(monthString); + const day = parseInt(dayString); + if (!isNumber(year) || !isNumber(month) || !isNumber(day)) return; + + return new Date(year, month - 1, day); + } catch (_err) { + return undefined; + } +}; + +/** + * @returns {string | null} formatted date in the format of MMM dd, yyyy + * @description Returns date in the formatted format + * @param {Date | string} date + * @example renderFormattedDate("2024-01-01") // Jan 01, 2024 + */ +export const renderFormattedDate = (date: string | Date | undefined | null): string | null => { + // Parse the date to check if it is valid + const parsedDate = getDate(date); + // return if undefined + if (!parsedDate) return null; + // Check if the parsed date is valid before formatting + if (!isValid(parsedDate)) return null; // Return null for invalid dates + // Format the date in format (MMM dd, yyyy) + const formattedDate = format(parsedDate, "MMM dd, yyyy"); + return formattedDate; +}; diff --git a/plane-src/apps/space/helpers/editor.helper.ts b/plane-src/apps/space/helpers/editor.helper.ts new file mode 100644 index 0000000..26c787d --- /dev/null +++ b/plane-src/apps/space/helpers/editor.helper.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// plane imports +import { MAX_FILE_SIZE } from "@plane/constants"; +import type { TFileHandler } from "@plane/editor"; +import { SitesFileService } from "@plane/services"; +import { getFileURL } from "@plane/utils"; +// services +const sitesFileService = new SitesFileService(); + +/** + * @description generate the file source using assetId + * @param {string} anchor + */ +export const getEditorAssetSrc = (anchor: string, assetId: string): string | undefined => { + const url = getFileURL(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`); + return url; +}; + +type TArgs = { + anchor: string; + uploadFile: TFileHandler["upload"]; + workspaceId: string; +}; + +/** + * @description this function returns the file handler required by the editors + * @param {TArgs} args + */ +export const getEditorFileHandlers = (args: TArgs): TFileHandler => { + const { anchor, uploadFile, workspaceId } = args; + + const getAssetSrc = async (path: string) => { + if (!path) return ""; + if (path?.startsWith("http")) { + return path; + } else { + return getEditorAssetSrc(anchor, path) ?? ""; + } + }; + + return { + checkIfAssetExists: async () => true, + assetsUploadStatus: {}, + getAssetDownloadSrc: getAssetSrc, + getAssetSrc: getAssetSrc, + upload: uploadFile, + delete: async (src: string) => { + if (src?.startsWith("http")) { + await sitesFileService.deleteOldEditorAsset(workspaceId, src); + } else { + await sitesFileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? ""); + } + }, + cancel: sitesFileService.cancelUpload, + restore: async (src: string) => { + if (src?.startsWith("http")) { + await sitesFileService.restoreOldEditorAsset(workspaceId, src); + } else { + await sitesFileService.restoreNewAsset(anchor, src); + } + }, + duplicate: async (assetId: string) => + // Duplication is not supported for sites/space app + // Return the same assetId as a fallback + assetId, + validation: { + maxFileSize: MAX_FILE_SIZE, + }, + }; +}; diff --git a/plane-src/apps/space/helpers/emoji.helper.tsx b/plane-src/apps/space/helpers/emoji.helper.tsx new file mode 100644 index 0000000..12d359a --- /dev/null +++ b/plane-src/apps/space/helpers/emoji.helper.tsx @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export const renderEmoji = ( + emoji: + | string + | { + name: string; + color: string; + } +) => { + if (!emoji) return; + + if (typeof emoji === "object") + return ( + + {emoji.name} + + ); + else return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji)); +}; + +export const groupReactions = (reactions: T[], key: string) => { + const groupedReactions = reactions.reduce( + (acc: { [key: string]: T[] }, reaction: any) => { + if (!acc[reaction[key]]) { + acc[reaction[key]] = []; + } + acc[reaction[key]].push(reaction); + return acc; + }, + {} as { [key: string]: T[] } + ); + + return groupedReactions; +}; diff --git a/plane-src/apps/space/helpers/file.helper.ts b/plane-src/apps/space/helpers/file.helper.ts new file mode 100644 index 0000000..1a5947e --- /dev/null +++ b/plane-src/apps/space/helpers/file.helper.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// plane imports +import { API_BASE_URL } from "@plane/constants"; + +/** + * @description combine the file path with the base URL + * @param {string} path + * @returns {string} final URL with the base URL + */ +export const getFileURL = (path: string): string | undefined => { + if (!path) return undefined; + const isValidURL = path.startsWith("http"); + if (isValidURL) return path; + return `${API_BASE_URL}${path}`; +}; + +/** + * @description this function returns the assetId from the asset source + * @param {string} src + * @returns {string} assetId + */ +export const getAssetIdFromUrl = (src: string): string => { + const sourcePaths = src.split("/"); + const assetUrl = sourcePaths[sourcePaths.length - 1]; + return assetUrl ?? ""; +}; diff --git a/plane-src/apps/space/helpers/issue.helper.ts b/plane-src/apps/space/helpers/issue.helper.ts new file mode 100644 index 0000000..aa11034 --- /dev/null +++ b/plane-src/apps/space/helpers/issue.helper.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays"; +// plane internal +import { STATE_GROUPS } from "@plane/constants"; +import type { TStateGroups } from "@plane/types"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; + +/** + * @description check if the issue due date should be highlighted + * @param date + * @param stateGroup + * @returns boolean + */ +export const shouldHighlightIssueDueDate = ( + date: string | Date | null, + stateGroup: TStateGroups | undefined +): boolean => { + if (!date || !stateGroup) return false; + // if the issue is completed or cancelled, don't highlight the due date + if ([STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateGroup)) return false; + + const parsedDate = getDate(date); + if (!parsedDate) return false; + + const targetDateDistance = differenceInCalendarDays(parsedDate, new Date()); + + // if the issue is overdue, highlight the due date + return targetDateDistance <= 0; +}; diff --git a/plane-src/apps/space/helpers/query-param-generator.ts b/plane-src/apps/space/helpers/query-param-generator.ts new file mode 100644 index 0000000..6f2ffcd --- /dev/null +++ b/plane-src/apps/space/helpers/query-param-generator.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +type TQueryParamValue = string | string[] | boolean | number | bigint | undefined | null; + +export const queryParamGenerator = (queryObject: Record) => { + const queryParamObject: Record = {}; + const queryParam = new URLSearchParams(); + + Object.entries(queryObject).forEach(([key, value]) => { + if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") { + queryParamObject[key] = value; + queryParam.append(key, value.toString()); + } else if (typeof value === "string" && value.length > 0) { + queryParamObject[key] = value.split(","); + queryParam.append(key, value); + } else if (Array.isArray(value) && value.length > 0) { + queryParamObject[key] = value; + queryParam.append(key, value.toString()); + } + }); + + return { + query: queryParamObject, + queryParam: queryParam.toString(), + }; +}; diff --git a/plane-src/apps/space/helpers/state.helper.ts b/plane-src/apps/space/helpers/state.helper.ts new file mode 100644 index 0000000..8a701dc --- /dev/null +++ b/plane-src/apps/space/helpers/state.helper.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { STATE_GROUPS } from "@plane/constants"; +import type { IState } from "@plane/types"; + +export const sortStates = (states: IState[]) => { + if (!states || states.length === 0) return; + + return states.sort((stateA, stateB) => { + if (stateA.group === stateB.group) { + return stateA.sequence - stateB.sequence; + } + return Object.keys(STATE_GROUPS).indexOf(stateA.group) - Object.keys(STATE_GROUPS).indexOf(stateB.group); + }); +}; diff --git a/plane-src/apps/space/helpers/string.helper.ts b/plane-src/apps/space/helpers/string.helper.ts new file mode 100644 index 0000000..674a4ba --- /dev/null +++ b/plane-src/apps/space/helpers/string.helper.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2"); + +const fallbackCopyTextToClipboard = (text: string) => { + const textArea = document.createElement("textarea"); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.top = "0"; + textArea.style.left = "0"; + textArea.style.position = "fixed"; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + // FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this. + // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand + document.execCommand("copy"); + } catch (_err) {} + + document.body.removeChild(textArea); +}; + +export const copyTextToClipboard = async (text: string) => { + if (!navigator.clipboard) { + fallbackCopyTextToClipboard(text); + return; + } + await navigator.clipboard.writeText(text); +}; + +/** + * @returns {boolean} true if email is valid, false otherwise + * @description Returns true if email is valid, false otherwise + * @param {string} email string to check if it is a valid email + * @example checkEmailIsValid("hello world") => false + * @example checkEmailIsValid("example@plane.so") => true + */ +export const checkEmailValidity = (email: string): boolean => { + if (!email) return false; + + const isEmailValid = + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + email + ); + + return isEmailValid; +}; + +export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); + +export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); + +/** + * @description + * This function test whether a URL is valid or not. + * + * It accepts URLs with or without the protocol. + * @param {string} url + * @returns {boolean} + * @example + * checkURLValidity("https://example.com") => true + * checkURLValidity("example.com") => true + * checkURLValidity("example") => false + */ +export const checkURLValidity = (url: string): boolean => { + if (!url) return false; + + // regex to support complex query parameters and fragments + const urlPattern = + /^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i; + + return urlPattern.test(url); +}; diff --git a/plane-src/apps/space/hooks/oauth/core.tsx b/plane-src/apps/space/hooks/oauth/core.tsx new file mode 100644 index 0000000..63a18cc --- /dev/null +++ b/plane-src/apps/space/hooks/oauth/core.tsx @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// plane imports +import { useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; +import { API_BASE_URL } from "@plane/constants"; +import type { TOAuthConfigs, TOAuthOption } from "@plane/types"; +// assets +import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; +import githubLightLogo from "@/app/assets/logos/github-black.png?url"; +import githubDarkLogo from "@/app/assets/logos/github-dark.svg?url"; +import gitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; +import googleLogo from "@/app/assets/logos/google-logo.svg?url"; +// hooks +import { useInstance } from "@/hooks/store/use-instance"; + +export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { + //router + const searchParams = useSearchParams(); + // query params + const next_path = searchParams.get("next_path"); + // theme + const { resolvedTheme } = useTheme(); + // store hooks + const { config } = useInstance(); + // derived values + const isOAuthEnabled = + (config && + (config?.is_google_enabled || + config?.is_github_enabled || + config?.is_gitlab_enabled || + config?.is_gitea_enabled)) || + false; + const oAuthOptions: TOAuthOption[] = [ + { + id: "google", + text: `${oauthActionText} with Google`, + icon: Google Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/google/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_google_enabled, + }, + { + id: "github", + text: `${oauthActionText} with GitHub`, + icon: ( + GitHub Logo + ), + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_github_enabled, + }, + { + id: "gitlab", + text: `${oauthActionText} with GitLab`, + icon: GitLab Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/gitlab/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_gitlab_enabled, + }, + { + id: "gitea", + text: `${oauthActionText} with Gitea`, + icon: Gitea Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/gitea/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_gitea_enabled, + }, + ]; + + return { + isOAuthEnabled, + oAuthOptions, + }; +}; diff --git a/plane-src/apps/space/hooks/oauth/extended.tsx b/plane-src/apps/space/hooks/oauth/extended.tsx new file mode 100644 index 0000000..9e7ca61 --- /dev/null +++ b/plane-src/apps/space/hooks/oauth/extended.tsx @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// plane imports +import type { TOAuthConfigs } from "@plane/types"; + +export const useExtendedOAuthConfig = (_oauthActionText: string): TOAuthConfigs => ({ + isOAuthEnabled: false, + oAuthOptions: [], +}); diff --git a/plane-src/apps/space/hooks/oauth/index.ts b/plane-src/apps/space/hooks/oauth/index.ts new file mode 100644 index 0000000..e88c743 --- /dev/null +++ b/plane-src/apps/space/hooks/oauth/index.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// plane imports +import type { TOAuthConfigs } from "@plane/types"; +// local imports +import { useCoreOAuthConfig } from "./core"; +import { useExtendedOAuthConfig } from "./extended"; + +export const useOAuthConfig = (oauthActionText: string = "Continue"): TOAuthConfigs => { + const coreOAuthConfig = useCoreOAuthConfig(oauthActionText); + const extendedOAuthConfig = useExtendedOAuthConfig(oauthActionText); + return { + isOAuthEnabled: coreOAuthConfig.isOAuthEnabled || extendedOAuthConfig.isOAuthEnabled, + oAuthOptions: [...coreOAuthConfig.oAuthOptions, ...extendedOAuthConfig.oAuthOptions], + }; +}; diff --git a/plane-src/apps/space/hooks/store/publish/index.ts b/plane-src/apps/space/hooks/store/publish/index.ts new file mode 100644 index 0000000..6b56541 --- /dev/null +++ b/plane-src/apps/space/hooks/store/publish/index.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./use-publish-list"; +export * from "./use-publish"; diff --git a/plane-src/apps/space/hooks/store/publish/use-publish-list.ts b/plane-src/apps/space/hooks/store/publish/use-publish-list.ts new file mode 100644 index 0000000..8a731bd --- /dev/null +++ b/plane-src/apps/space/hooks/store/publish/use-publish-list.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IPublishListStore } from "@/store/publish/publish_list.store"; + +export const usePublishList = (): IPublishListStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("usePublishList must be used within StoreProvider"); + return context.publishList; +}; diff --git a/plane-src/apps/space/hooks/store/publish/use-publish.ts b/plane-src/apps/space/hooks/store/publish/use-publish.ts new file mode 100644 index 0000000..50ea527 --- /dev/null +++ b/plane-src/apps/space/hooks/store/publish/use-publish.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { PublishStore } from "@/store/publish/publish.store"; + +export const usePublish = (anchor: string): PublishStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("usePublish must be used within StoreProvider"); + return context.publishList.publishMap?.[anchor] ?? {}; +}; diff --git a/plane-src/apps/space/hooks/store/use-cycle.ts b/plane-src/apps/space/hooks/store/use-cycle.ts new file mode 100644 index 0000000..4b7e9e4 --- /dev/null +++ b/plane-src/apps/space/hooks/store/use-cycle.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { ICycleStore } from "@/store/cycle.store"; + +export const useCycle = (): ICycleStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useCycle must be used within StoreProvider"); + return context.cycle; +}; diff --git a/plane-src/apps/space/hooks/store/use-instance.ts b/plane-src/apps/space/hooks/store/use-instance.ts new file mode 100644 index 0000000..3f783a9 --- /dev/null +++ b/plane-src/apps/space/hooks/store/use-instance.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IInstanceStore } from "@/store/instance.store"; + +export const useInstance = (): IInstanceStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + return context.instance; +}; diff --git a/plane-src/apps/space/hooks/store/use-issue-details.tsx b/plane-src/apps/space/hooks/store/use-issue-details.tsx new file mode 100644 index 0000000..a30403e --- /dev/null +++ b/plane-src/apps/space/hooks/store/use-issue-details.tsx @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IIssueDetailStore } from "@/store/issue-detail.store"; + +export const useIssueDetails = (): IIssueDetailStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + return context.issueDetail; +}; diff --git a/plane-src/apps/space/hooks/store/use-issue-filter.ts b/plane-src/apps/space/hooks/store/use-issue-filter.ts new file mode 100644 index 0000000..1f60bae --- /dev/null +++ b/plane-src/apps/space/hooks/store/use-issue-filter.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IIssueFilterStore } from "@/store/issue-filters.store"; + +export const useIssueFilter = (): IIssueFilterStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + return context.issueFilter; +}; diff --git a/plane-src/apps/space/hooks/store/use-issue.ts b/plane-src/apps/space/hooks/store/use-issue.ts new file mode 100644 index 0000000..808117e --- /dev/null +++ b/plane-src/apps/space/hooks/store/use-issue.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IIssueStore } from "@/store/issue.store"; + +export const useIssue = (): IIssueStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useIssue must be used within StoreProvider"); + return context.issue; +}; diff --git a/plane-src/apps/space/hooks/store/use-label.ts b/plane-src/apps/space/hooks/store/use-label.ts new file mode 100644 index 0000000..f7c2618 --- /dev/null +++ b/plane-src/apps/space/hooks/store/use-label.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IIssueLabelStore } from "@/store/label.store"; + +export const useLabel = (): IIssueLabelStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useLabel must be used within StoreProvider"); + return context.label; +}; diff --git a/plane-src/apps/space/hooks/store/use-member.ts b/plane-src/apps/space/hooks/store/use-member.ts new file mode 100644 index 0000000..82443db --- /dev/null +++ b/plane-src/apps/space/hooks/store/use-member.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IIssueMemberStore } from "@/store/members.store"; + +export const useMember = (): IIssueMemberStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useMember must be used within StoreProvider"); + return context.member; +}; diff --git a/plane-src/apps/space/hooks/store/use-module.ts b/plane-src/apps/space/hooks/store/use-module.ts new file mode 100644 index 0000000..c2d5768 --- /dev/null +++ b/plane-src/apps/space/hooks/store/use-module.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IIssueModuleStore } from "@/store/module.store"; + +export const useModule = (): IIssueModuleStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useModule must be used within StoreProvider"); + return context.module; +}; diff --git a/plane-src/apps/space/hooks/store/use-state.ts b/plane-src/apps/space/hooks/store/use-state.ts new file mode 100644 index 0000000..8f23977 --- /dev/null +++ b/plane-src/apps/space/hooks/store/use-state.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IStateStore } from "@/store/state.store"; + +export const useStates = (): IStateStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useState must be used within StoreProvider"); + return context.state; +}; diff --git a/plane-src/apps/space/hooks/store/use-user-profile.ts b/plane-src/apps/space/hooks/store/use-user-profile.ts new file mode 100644 index 0000000..ec53da9 --- /dev/null +++ b/plane-src/apps/space/hooks/store/use-user-profile.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IProfileStore } from "@/store/profile.store"; + +export const useUserProfile = (): IProfileStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + return context.user.profile; +}; diff --git a/plane-src/apps/space/hooks/store/use-user.ts b/plane-src/apps/space/hooks/store/use-user.ts new file mode 100644 index 0000000..b3af67a --- /dev/null +++ b/plane-src/apps/space/hooks/store/use-user.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import type { IUserStore } from "@/store/user.store"; + +export const useUser = (): IUserStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useUser must be used within StoreProvider"); + return context.user; +}; diff --git a/plane-src/apps/space/hooks/use-clipboard-write-permission.tsx b/plane-src/apps/space/hooks/use-clipboard-write-permission.tsx new file mode 100644 index 0000000..8b1bf16 --- /dev/null +++ b/plane-src/apps/space/hooks/use-clipboard-write-permission.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState, useEffect } from "react"; + +const useClipboardWritePermission = () => { + const [isClipboardWriteAllowed, setClipboardWriteAllowed] = useState(false); + + useEffect(() => { + const checkClipboardWriteAccess = () => { + navigator.permissions + + .query({ name: "clipboard-write" as PermissionName }) + .then((result) => { + if (result.state === "granted") { + setClipboardWriteAllowed(true); + } else { + setClipboardWriteAllowed(false); + } + }) + .catch(() => { + setClipboardWriteAllowed(false); + }); + }; + + checkClipboardWriteAccess(); + }, []); + + return isClipboardWriteAllowed; +}; + +export default useClipboardWritePermission; diff --git a/plane-src/apps/space/hooks/use-editor-flagging.ts b/plane-src/apps/space/hooks/use-editor-flagging.ts new file mode 100644 index 0000000..88e82f7 --- /dev/null +++ b/plane-src/apps/space/hooks/use-editor-flagging.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// editor +import type { TExtensions } from "@plane/editor"; + +export type TEditorFlaggingHookReturnType = { + document: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; + liteText: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; + richText: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; +}; + +/** + * @description extensions disabled in various editors + */ +export const useEditorFlagging = (_anchor: string): TEditorFlaggingHookReturnType => ({ + document: { + disabled: [], + flagged: [], + }, + liteText: { + disabled: [], + flagged: [], + }, + richText: { + disabled: [], + flagged: [], + }, +}); diff --git a/plane-src/apps/space/hooks/use-intersection-observer.tsx b/plane-src/apps/space/hooks/use-intersection-observer.tsx new file mode 100644 index 0000000..4cf9a27 --- /dev/null +++ b/plane-src/apps/space/hooks/use-intersection-observer.tsx @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { RefObject } from "react"; +import { useEffect } from "react"; + +export type UseIntersectionObserverProps = { + containerRef: RefObject | undefined; + elementRef: HTMLElement | null; + callback: () => void; + rootMargin?: string; +}; + +export const useIntersectionObserver = ( + containerRef: RefObject, + elementRef: HTMLElement | null, + callback: (() => void) | undefined, + rootMargin?: string +) => { + useEffect(() => { + if (elementRef) { + const observer = new IntersectionObserver( + (entries) => { + if (entries[entries.length - 1].isIntersecting) { + if (callback) { + callback(); + } + } + }, + { + root: containerRef?.current, + rootMargin, + } + ); + observer.observe(elementRef); + return () => { + if (elementRef) { + observer.unobserve(elementRef); + } + }; + } + // When i am passing callback as a dependency, it is causing infinite loop, + // Please make sure you fix this eslint lint disable error with caution + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rootMargin, callback, elementRef, containerRef.current]); +}; diff --git a/plane-src/apps/space/hooks/use-is-in-iframe.tsx b/plane-src/apps/space/hooks/use-is-in-iframe.tsx new file mode 100644 index 0000000..3fb5b55 --- /dev/null +++ b/plane-src/apps/space/hooks/use-is-in-iframe.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState, useEffect } from "react"; + +const useIsInIframe = () => { + const [isInIframe, setIsInIframe] = useState(false); + + useEffect(() => { + const checkIfInIframe = () => { + setIsInIframe(window.self !== window.top); + }; + + checkIfInIframe(); + }, []); + + return isInIframe; +}; + +export default useIsInIframe; diff --git a/plane-src/apps/space/hooks/use-mention.tsx b/plane-src/apps/space/hooks/use-mention.tsx new file mode 100644 index 0000000..e54ff3e --- /dev/null +++ b/plane-src/apps/space/hooks/use-mention.tsx @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useRef, useEffect } from "react"; +import useSWR from "swr"; +// plane imports +import { UserService } from "@plane/services"; +import type { IUser } from "@plane/types"; + +export const useMention = () => { + const userService = new UserService(); + const { data: user, isLoading: userDataLoading } = useSWR("currentUser", async () => userService.me()); + + const userRef = useRef(); + + useEffect(() => { + if (userRef) { + userRef.current = user; + } + }, [user]); + + const waitForUserDate = async () => + new Promise((resolve) => { + const checkData = () => { + if (userRef.current) { + resolve(userRef.current); + } else { + setTimeout(checkData, 100); + } + }; + checkData(); + }); + + const mentionHighlights = async () => { + if (!userDataLoading && userRef.current) { + return [userRef.current.id]; + } else { + const user = await waitForUserDate(); + return [user.id]; + } + }; + + return { + mentionHighlights, + }; +}; diff --git a/plane-src/apps/space/hooks/use-parse-editor-content.ts b/plane-src/apps/space/hooks/use-parse-editor-content.ts new file mode 100644 index 0000000..ec13194 --- /dev/null +++ b/plane-src/apps/space/hooks/use-parse-editor-content.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback } from "react"; +// helpers +import type { TCustomComponentsMetaData } from "@plane/utils"; +// helpers +import { getEditorAssetSrc } from "@/helpers/editor.helper"; +// hooks +import { useMember } from "@/hooks/store/use-member"; + +type TArgs = { + anchor: string; +}; + +export const useParseEditorContent = (args: TArgs) => { + const { anchor } = args; + // store hooks + const { getMemberById } = useMember(); + + const getEditorMetaData = useCallback( + (htmlContent: string): TCustomComponentsMetaData => { + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlContent, "text/html"); + const imageMetaData: TCustomComponentsMetaData["file_assets"] = []; + // process image components + const imageComponents = doc.querySelectorAll("image-component"); + imageComponents.forEach((element) => { + const src = element.getAttribute("src"); + if (src) { + const assetSrc = src.startsWith("http") ? src : getEditorAssetSrc(anchor, src); + if (assetSrc) { + imageMetaData.push({ + id: src, + name: src, + url: assetSrc, + }); + } + } + }); + // process user mentions + const userMentions: TCustomComponentsMetaData["user_mentions"] = []; + const mentionComponents = doc.querySelectorAll("mention-component"); + mentionComponents.forEach((element) => { + const id = element.getAttribute("entity_identifier"); + if (id) { + const userDetails = getMemberById(id); + const originUrl = typeof window !== "undefined" && (window.location.origin ?? ""); + const path = `profile/${id}`; + const url = `${originUrl}/${path}`; + if (userDetails) { + userMentions.push({ + id, + display_name: userDetails.member__display_name, + url, + }); + } + } + }); + + return { + file_assets: imageMetaData, + user_mentions: userMentions, + }; + }, + [anchor, getMemberById] + ); + + return { + getEditorMetaData, + }; +}; diff --git a/plane-src/apps/space/hooks/use-timer.tsx b/plane-src/apps/space/hooks/use-timer.tsx new file mode 100644 index 0000000..c663d80 --- /dev/null +++ b/plane-src/apps/space/hooks/use-timer.tsx @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState, useEffect } from "react"; + +const TIMER = 30; + +const useTimer = (initialValue: number = TIMER) => { + const [timer, setTimer] = useState(initialValue); + + useEffect(() => { + const interval = setInterval(() => { + setTimer((prev) => prev - 1); + }, 1000); + + return () => clearInterval(interval); + }, []); + + return { timer, setTimer }; +}; + +export default useTimer; diff --git a/plane-src/apps/space/lib/b-progress/AppProgressBar.tsx b/plane-src/apps/space/lib/b-progress/AppProgressBar.tsx new file mode 100644 index 0000000..e436258 --- /dev/null +++ b/plane-src/apps/space/lib/b-progress/AppProgressBar.tsx @@ -0,0 +1,143 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect, useRef } from "react"; +import { BProgress } from "@bprogress/core"; +import { useNavigation } from "react-router"; +import "@bprogress/core/css"; + +/** + * Progress bar configuration options + */ +interface ProgressConfig { + /** Whether to show the loading spinner */ + showSpinner: boolean; + /** Minimum progress percentage (0-1) */ + minimum: number; + /** Animation speed in milliseconds */ + speed: number; + /** Auto-increment speed in milliseconds */ + trickleSpeed: number; + /** CSS easing function */ + easing: string; + /** Enable auto-increment */ + trickle: boolean; + /** Delay before showing progress bar in milliseconds */ + delay: number; + /** Whether to disable the progress bar */ + isDisabled?: boolean; +} + +/** + * Configuration for the progress bar + */ +const PROGRESS_CONFIG: Readonly = { + showSpinner: false, + minimum: 0.1, + speed: 400, + trickleSpeed: 800, + easing: "ease", + trickle: true, + delay: 0, +} as const; + +/** + * Navigation Progress Bar Component + * + * Automatically displays a progress bar at the top of the page during React Router navigation. + * Integrates with React Router's useNavigation hook to monitor route changes. + * + * Note: Progress bar is disabled in production builds. + * + * @returns null - This component doesn't render any visible elements + * + * @example + * ```tsx + * function App() { + * return ( + * <> + * + * + * + * ); + * } + * ``` + */ +export function AppProgressBar(): null { + const navigation = useNavigation(); + const timerRef = useRef | null>(null); + const startedRef = useRef(false); + + // Initialize BProgress once on mount + useEffect(() => { + // Skip initialization in production builds + if (PROGRESS_CONFIG.isDisabled) { + return; + } + + // Configure BProgress with our settings + BProgress.configure({ + showSpinner: PROGRESS_CONFIG.showSpinner, + minimum: PROGRESS_CONFIG.minimum, + speed: PROGRESS_CONFIG.speed, + trickleSpeed: PROGRESS_CONFIG.trickleSpeed, + easing: PROGRESS_CONFIG.easing, + trickle: PROGRESS_CONFIG.trickle, + }); + + // Render the progress bar element in the DOM + BProgress.render(true); + + // Cleanup on unmount + return () => { + if (BProgress.isStarted()) { + BProgress.done(); + } + }; + }, []); + + // Handle navigation state changes + useEffect(() => { + // Skip navigation tracking in production builds + if (PROGRESS_CONFIG.isDisabled) { + return; + } + + if (navigation.state === "idle") { + // Navigation complete - clear any pending timer + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + + // Complete progress if it was started + if (startedRef.current) { + BProgress.done(); + startedRef.current = false; + } + } else { + // Navigation in progress (loading or submitting) + // Only start if not already started and no timer pending + if (timerRef.current === null && !startedRef.current) { + timerRef.current = setTimeout((): void => { + if (!BProgress.isStarted()) { + BProgress.start(); + startedRef.current = true; + } + timerRef.current = null; + }, PROGRESS_CONFIG.delay); + } + } + + return () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + } + }; + }, [navigation.state]); + + return null; +} diff --git a/plane-src/apps/space/lib/b-progress/index.tsx b/plane-src/apps/space/lib/b-progress/index.tsx new file mode 100644 index 0000000..5920172 --- /dev/null +++ b/plane-src/apps/space/lib/b-progress/index.tsx @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./AppProgressBar"; diff --git a/plane-src/apps/space/lib/instance-provider.tsx b/plane-src/apps/space/lib/instance-provider.tsx new file mode 100644 index 0000000..bd18f81 --- /dev/null +++ b/plane-src/apps/space/lib/instance-provider.tsx @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { Link } from "react-router"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +// plane imports +import { SPACE_BASE_PATH } from "@plane/constants"; +import { PlaneLockup } from "@plane/propel/icons"; +// assets +import PlaneBackgroundPatternDark from "@/app/assets/auth/background-pattern-dark.svg?url"; +import PlaneBackgroundPattern from "@/app/assets/auth/background-pattern.svg?url"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { InstanceFailureView } from "@/components/instance/instance-failure-view"; +// hooks +import { useInstance } from "@/hooks/store/use-instance"; +import { useUser } from "@/hooks/store/use-user"; + +export const InstanceProvider = observer(function InstanceProvider({ children }: { children: React.ReactNode }) { + const { fetchInstanceInfo, instance, error } = useInstance(); + const { fetchCurrentUser } = useUser(); + const { resolvedTheme } = useTheme(); + + const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; + + useSWR("INSTANCE_INFO", () => fetchInstanceInfo(), { + revalidateOnFocus: false, + revalidateIfStale: false, + errorRetryCount: 0, + }); + useSWR("CURRENT_USER", () => fetchCurrentUser(), { + shouldRetryOnError: false, + revalidateOnFocus: true, + revalidateIfStale: true, + }); + + if (!instance && !error) + return ( +
+ +
+ ); + + if (error) { + return ( +
+
+
+
+ + + +
+
+
+ Plane background pattern +
+
+
+ +
+
+
+
+ ); + } + + return children; +}); diff --git a/plane-src/apps/space/lib/store-provider.tsx b/plane-src/apps/space/lib/store-provider.tsx new file mode 100644 index 0000000..2987590 --- /dev/null +++ b/plane-src/apps/space/lib/store-provider.tsx @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { createContext } from "react"; +// plane web store +import { RootStore } from "@/store/root.store"; + +let rootStore = new RootStore(); + +export const StoreContext = createContext(rootStore); + +function initializeStore() { + const singletonRootStore = rootStore ?? new RootStore(); + // For SSG and SSR always create a new store + if (typeof window === "undefined") return singletonRootStore; + // Create the store once in the client + if (!rootStore) rootStore = singletonRootStore; + return singletonRootStore; +} + +export type StoreProviderProps = { + children: React.ReactNode; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initialState?: any; +}; + +export function StoreProvider({ children, initialState = undefined }: StoreProviderProps) { + const store = initializeStore(); + // If your page has Next.js data fetching methods that use a Mobx store, it will + // get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details + if (initialState) { + store.hydrate(initialState); + } + + return {children}; +} diff --git a/plane-src/apps/space/lib/toast-provider.tsx b/plane-src/apps/space/lib/toast-provider.tsx new file mode 100644 index 0000000..420c801 --- /dev/null +++ b/plane-src/apps/space/lib/toast-provider.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useTheme } from "next-themes"; +// plane imports +import { Toast } from "@plane/propel/toast"; +import { resolveGeneralTheme } from "@plane/utils"; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + // themes + const { resolvedTheme } = useTheme(); + + return ( + <> + + {children} + + ); +} diff --git a/plane-src/apps/space/nginx/nginx.conf b/plane-src/apps/space/nginx/nginx.conf new file mode 100644 index 0000000..c0fe373 --- /dev/null +++ b/plane-src/apps/space/nginx/nginx.conf @@ -0,0 +1,30 @@ +worker_processes 4; + +events { + worker_connections 1024; +} + +http { + include mime.types; + + default_type application/octet-stream; + + set_real_ip_from 0.0.0.0/0; + real_ip_recursive on; + real_ip_header X-Forward-For; + limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s; + + access_log /dev/stdout; + error_log /dev/stderr; + + server { + listen 3000; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /spaces/index.html; + } + } +} + diff --git a/plane-src/apps/space/package.json b/plane-src/apps/space/package.json new file mode 100644 index 0000000..4500152 --- /dev/null +++ b/plane-src/apps/space/package.json @@ -0,0 +1,69 @@ +{ + "name": "space", + "version": "1.3.0", + "private": true, + "license": "AGPL-3.0", + "type": "module", + "scripts": { + "dev": "react-router dev --port 3002", + "build": "react-router build", + "preview": "react-router build && PORT=3002 react-router-serve ./build/server/index.js", + "start": "PORT=3002 react-router-serve ./build/server/index.js", + "clean": "rm -rf .turbo && rm -rf .next && rm -rf .react-router && rm -rf node_modules && rm -rf dist && rm -rf build", + "check:lint": "oxlint --max-warnings=676 .", + "check:types": "react-router typegen && tsc --noEmit", + "check:format": "oxfmt --check .", + "fix:lint": "oxlint --fix .", + "fix:format": "oxfmt ." + }, + "dependencies": { + "@bprogress/core": "catalog:", + "@fontsource-variable/inter": "5.2.8", + "@fontsource/ibm-plex-mono": "5.2.7", + "@fontsource/material-symbols-rounded": "5.2.30", + "@headlessui/react": "^1.7.19", + "@plane/constants": "workspace:*", + "@plane/editor": "workspace:*", + "@plane/i18n": "workspace:*", + "@plane/propel": "workspace:*", + "@plane/services": "workspace:*", + "@plane/types": "workspace:*", + "@plane/ui": "workspace:*", + "@plane/utils": "workspace:*", + "@popperjs/core": "^2.11.8", + "@react-router/node": "catalog:", + "@react-router/serve": "catalog:", + "axios": "catalog:", + "clsx": "^2.0.0", + "date-fns": "^4.1.0", + "isbot": "^5.1.31", + "lodash-es": "catalog:", + "lucide-react": "catalog:", + "mobx": "catalog:", + "mobx-react": "catalog:", + "mobx-utils": "catalog:", + "next-themes": "0.4.6", + "react": "catalog:", + "react-dom": "catalog:", + "react-dropzone": "^14.2.3", + "react-hook-form": "7.51.5", + "react-popper": "^2.3.0", + "react-router": "catalog:", + "swr": "catalog:", + "uuid": "catalog:" + }, + "devDependencies": { + "@plane/tailwind-config": "workspace:*", + "@plane/typescript-config": "workspace:*", + "@react-router/dev": "catalog:", + "@tailwindcss/typography": "0.5.19", + "@types/lodash-es": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "dotenv": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/plane-src/apps/space/postcss.config.js b/plane-src/apps/space/postcss.config.js new file mode 100644 index 0000000..3ad28f1 --- /dev/null +++ b/plane-src/apps/space/postcss.config.js @@ -0,0 +1,3 @@ +import postcssConfig from "@plane/tailwind-config/postcss.config.js"; + +export default postcssConfig; diff --git a/plane-src/apps/space/public/favicon/android-chrome-192x192.png b/plane-src/apps/space/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000..4a005e5 Binary files /dev/null and b/plane-src/apps/space/public/favicon/android-chrome-192x192.png differ diff --git a/plane-src/apps/space/public/favicon/android-chrome-512x512.png b/plane-src/apps/space/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000..27fafe8 Binary files /dev/null and b/plane-src/apps/space/public/favicon/android-chrome-512x512.png differ diff --git a/plane-src/apps/space/public/site.webmanifest.json b/plane-src/apps/space/public/site.webmanifest.json new file mode 100644 index 0000000..8885d13 --- /dev/null +++ b/plane-src/apps/space/public/site.webmanifest.json @@ -0,0 +1,13 @@ +{ + "name": "Plane Space", + "short_name": "Plane Space", + "description": "Plane helps you plan your work items, cycles, and product modules.", + "start_url": ".", + "display": "standalone", + "background_color": "#f9fafb", + "theme_color": "#3f76ff", + "icons": [ + { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/plane-src/apps/space/react-router.config.ts b/plane-src/apps/space/react-router.config.ts new file mode 100644 index 0000000..78a1743 --- /dev/null +++ b/plane-src/apps/space/react-router.config.ts @@ -0,0 +1,10 @@ +import type { Config } from "@react-router/dev/config"; +import { joinUrlPath } from "@plane/utils"; + +const basePath = joinUrlPath(process.env.VITE_SPACE_BASE_PATH ?? "", "/") ?? "/"; + +export default { + appDirectory: "app", + basename: basePath, + ssr: true, +} satisfies Config; diff --git a/plane-src/apps/space/store/cycle.store.ts b/plane-src/apps/space/store/cycle.store.ts new file mode 100644 index 0000000..13cb695 --- /dev/null +++ b/plane-src/apps/space/store/cycle.store.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { action, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesCycleService } from "@plane/services"; +import type { TPublicCycle } from "@/types/cycle"; +// store +import type { RootStore } from "./root.store"; + +export interface ICycleStore { + // observables + cycles: TPublicCycle[] | undefined; + // computed actions + getCycleById: (cycleId: string | undefined) => TPublicCycle | undefined; + // fetch actions + fetchCycles: (anchor: string) => Promise; +} + +export class CycleStore implements ICycleStore { + cycles: TPublicCycle[] | undefined = undefined; + cycleService: SitesCycleService; + rootStore: RootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + cycles: observable, + // fetch action + fetchCycles: action, + }); + this.cycleService = new SitesCycleService(); + this.rootStore = _rootStore; + } + + getCycleById = (cycleId: string | undefined) => this.cycles?.find((cycle) => cycle.id === cycleId); + + fetchCycles = async (anchor: string) => { + const cyclesResponse = await this.cycleService.list(anchor); + runInAction(() => { + this.cycles = cyclesResponse; + }); + return cyclesResponse; + }; +} diff --git a/plane-src/apps/space/store/helpers/base-issues.store.ts b/plane-src/apps/space/store/helpers/base-issues.store.ts new file mode 100644 index 0000000..e0fdb56 --- /dev/null +++ b/plane-src/apps/space/store/helpers/base-issues.store.ts @@ -0,0 +1,521 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { concat, get, set, uniq, update } from "lodash-es"; +import { action, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// plane imports +import { ALL_ISSUES } from "@plane/constants"; +import { SitesIssueService } from "@plane/services"; +import type { + TIssueGroupByOptions, + TGroupedIssues, + TSubGroupedIssues, + TLoader, + IssuePaginationOptions, + TIssues, + TIssuePaginationData, + TGroupedIssueCount, + TPaginationData, +} from "@plane/types"; +// types +import type { IIssue, TIssuesResponse } from "@/types/issue"; +import type { RootStore } from "../root.store"; +// constants +// helpers + +export type TIssueDisplayFilterOptions = Exclude | "target_date"; + +export enum EIssueGroupedAction { + ADD = "ADD", + DELETE = "DELETE", + REORDER = "REORDER", +} + +export interface IBaseIssuesStore { + // observable + loader: Record; + // actions + addIssue(issues: IIssue[], shouldReplace?: boolean): void; + // helper methods + groupedIssueIds: TGroupedIssues | TSubGroupedIssues | undefined; // object to store Issue Ids based on group or subgroup + groupedIssueCount: TGroupedIssueCount; // map of groupId/subgroup and issue count of that particular group/subgroup + issuePaginationData: TIssuePaginationData; // map of groupId/subgroup and pagination Data of that particular group/subgroup + + // helper methods + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; + getPaginationData(groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined; + getIssueLoader(groupId?: string, subGroupId?: string): TLoader; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; +} + +export const ISSUE_FILTER_DEFAULT_DATA: Record = { + project: "project_id", + cycle: "cycle_id", + module: "module_ids", + state: "state_id", + "state_detail.group": "state_group" as keyof IIssue, // state_detail.group is only being used for state_group display, + priority: "priority", + labels: "label_ids", + created_by: "created_by", + assignees: "assignee_ids", + target_date: "target_date", +}; + +export abstract class BaseIssuesStore implements IBaseIssuesStore { + loader: Record = {}; + groupedIssueIds: TIssues | undefined = undefined; + issuePaginationData: TIssuePaginationData = {}; + groupedIssueCount: TGroupedIssueCount = {}; + // + paginationOptions: IssuePaginationOptions | undefined = undefined; + + issueService; + // root store + rootIssueStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observable + loader: observable, + groupedIssueIds: observable, + issuePaginationData: observable, + groupedIssueCount: observable, + + paginationOptions: observable, + // action + storePreviousPaginationValues: action.bound, + + onfetchIssues: action.bound, + onfetchNexIssues: action.bound, + clear: action.bound, + setLoader: action.bound, + }); + this.rootIssueStore = _rootStore; + this.issueService = new SitesIssueService(); + } + + getIssueIds = (groupId?: string, subGroupId?: string) => { + const groupedIssueIds = this.groupedIssueIds; + + if (!groupedIssueIds) return undefined; + + const allIssues = groupedIssueIds[ALL_ISSUES] ?? []; + if (allIssues && Array.isArray(allIssues)) { + return allIssues; + } + + if (groupId && groupedIssueIds?.[groupId] && Array.isArray(groupedIssueIds[groupId])) { + return groupedIssueIds[groupId] ?? []; + } + + if (groupId && subGroupId) { + return (groupedIssueIds as TSubGroupedIssues)[groupId]?.[subGroupId] ?? []; + } + + return undefined; + }; + + /** + * @description This method will add issues to the issuesMap + * @param {IIssue[]} issues + * @returns {void} + */ + addIssue = (issues: IIssue[], shouldReplace = false) => { + if (issues && issues.length <= 0) return; + runInAction(() => { + issues.forEach((issue) => { + if (!this.rootIssueStore.issueDetail.getIssueById(issue.id) || shouldReplace) + set(this.rootIssueStore.issueDetail.details, issue.id, issue); + }); + }); + }; + + /** + * Store the pagination data required for next subsequent issue pagination calls + * @param prevCursor cursor value of previous page + * @param nextCursor cursor value of next page + * @param nextPageResults boolean to indicate if the next page results exist i.e, have we reached end of pages + * @param groupId groupId and subGroupId to add the pagination data for the particular group/subgroup + * @param subGroupId + */ + setPaginationData( + prevCursor: string, + nextCursor: string, + nextPageResults: boolean, + groupId?: string, + subGroupId?: string + ) { + const cursorObject = { + prevCursor, + nextCursor, + nextPageResults, + }; + + set(this.issuePaginationData, [this.getGroupKey(groupId, subGroupId)], cursorObject); + } + + /** + * Sets the loader value of the particular groupId/subGroupId, or to ALL_ISSUES if both are undefined + * @param loaderValue + * @param groupId + * @param subGroupId + */ + setLoader(loaderValue: TLoader, groupId?: string, subGroupId?: string) { + runInAction(() => { + set(this.loader, this.getGroupKey(groupId, subGroupId), loaderValue); + }); + } + + /** + * gets the Loader value of particular group/subgroup/ALL_ISSUES + */ + getIssueLoader = (groupId?: string, subGroupId?: string) => get(this.loader, this.getGroupKey(groupId, subGroupId)); + + /** + * gets the pagination data of particular group/subgroup/ALL_ISSUES + */ + getPaginationData = computedFn( + (groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined => + get(this.issuePaginationData, [this.getGroupKey(groupId, subGroupId)]) + ); + + /** + * gets the issue count of particular group/subgroup/ALL_ISSUES + * + * if isSubGroupCumulative is true, sum up all the issueCount of the subGroupId, across all the groupIds + */ + getGroupIssueCount = computedFn( + ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ): number | undefined => { + if (isSubGroupCumulative && subGroupId) { + const groupIssuesKeys = Object.keys(this.groupedIssueCount); + let subGroupCumulativeCount = 0; + + for (const groupKey of groupIssuesKeys) { + if (groupKey.includes(`_${subGroupId}`)) subGroupCumulativeCount += this.groupedIssueCount[groupKey]; + } + + return subGroupCumulativeCount; + } + + return get(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)]); + } + ); + + /** + * This Method is called after fetching the first paginated issues + * + * This method updates the appropriate issue list based on if groupByKey or subGroupByKey are defined + * If both groupByKey and subGroupByKey are not defined, then the issue list are added to another group called ALL_ISSUES + * @param issuesResponse Paginated Response received from the API + * @param options Pagination options + * @param workspaceSlug + * @param projectId + * @param id Id can be anything from cycleId, moduleId, viewId or userId based on the store + */ + onfetchIssues(issuesResponse: TIssuesResponse, options: IssuePaginationOptions) { + // Process the Issue Response to get the following data from it + const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse); + + // The Issue list is added to the main Issue Map + this.addIssue(issueList); + + // Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts + runInAction(() => { + this.updateGroupedIssueIds(groupedIssues, groupedIssueCount); + this.loader[this.getGroupKey()] = undefined; + }); + + // store Pagination options for next subsequent calls and data like next cursor etc + this.storePreviousPaginationValues(issuesResponse, options); + } + + /** + * This Method is called on the subsequent pagination calls after the first initial call + * + * This method updates the appropriate issue list based on if groupId or subgroupIds are Passed + * @param issuesResponse Paginated Response received from the API + * @param groupId + * @param subGroupId + */ + onfetchNexIssues(issuesResponse: TIssuesResponse, groupId?: string, subGroupId?: string) { + // Process the Issue Response to get the following data from it + const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse); + + // The Issue list is added to the main Issue Map + this.addIssue(issueList); + + // Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts + runInAction(() => { + this.updateGroupedIssueIds(groupedIssues, groupedIssueCount, groupId, subGroupId); + this.loader[this.getGroupKey(groupId, subGroupId)] = undefined; + }); + + // store Pagination data like next cursor etc + this.storePreviousPaginationValues(issuesResponse, undefined, groupId, subGroupId); + } + + /** + * Method called to clear out the current store + */ + clear(shouldClearPaginationOptions = true) { + runInAction(() => { + this.groupedIssueIds = undefined; + this.issuePaginationData = {}; + this.groupedIssueCount = {}; + if (shouldClearPaginationOptions) { + this.paginationOptions = undefined; + } + }); + } + + /** + * This method processes the issueResponse to provide data that can be used to update the store + * @param issueResponse + * @returns issueList, list of issue Data + * @returns groupedIssues, grouped issue Ids + * @returns groupedIssueCount, object containing issue counts of individual groups + */ + processIssueResponse(issueResponse: TIssuesResponse): { + issueList: IIssue[]; + groupedIssues: TIssues; + groupedIssueCount: TGroupedIssueCount; + } { + const issueResult = issueResponse?.results; + + // if undefined return empty objects + if (!issueResult) + return { + issueList: [], + groupedIssues: {}, + groupedIssueCount: {}, + }; + + //if is an array then it's an ungrouped response. return values with groupId as ALL_ISSUES + if (Array.isArray(issueResult)) { + return { + issueList: issueResult, + groupedIssues: { + [ALL_ISSUES]: issueResult.map((issue) => issue.id), + }, + groupedIssueCount: { + [ALL_ISSUES]: issueResponse.total_count, + }, + }; + } + + const issueList: IIssue[] = []; + const groupedIssues: TGroupedIssues | TSubGroupedIssues = {}; + const groupedIssueCount: TGroupedIssueCount = {}; + + // update total issue count to ALL_ISSUES + set(groupedIssueCount, [ALL_ISSUES], issueResponse.total_count); + + // loop through all the groupIds from issue Result + for (const groupId in issueResult) { + const groupIssuesObject = issueResult[groupId]; + const groupIssueResult = groupIssuesObject?.results; + + // if groupIssueResult is undefined then continue the loop + if (!groupIssueResult) continue; + + // set grouped Issue count of the current groupId + set(groupedIssueCount, [groupId], groupIssuesObject.total_results); + + // if groupIssueResult, the it is not subGrouped + if (Array.isArray(groupIssueResult)) { + // add the result to issueList + issueList.push(...groupIssueResult); + // set the issue Ids to the groupId path + set( + groupedIssues, + [groupId], + groupIssueResult.map((issue) => issue.id) + ); + continue; + } + + // loop through all the subGroupIds from issue Result + for (const subGroupId in groupIssueResult) { + const subGroupIssuesObject = groupIssueResult[subGroupId]; + const subGroupIssueResult = subGroupIssuesObject?.results; + + // if subGroupIssueResult is undefined then continue the loop + if (!subGroupIssueResult) continue; + + // set sub grouped Issue count of the current groupId + set(groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], subGroupIssuesObject.total_results); + + if (Array.isArray(subGroupIssueResult)) { + // add the result to issueList + issueList.push(...subGroupIssueResult); + // set the issue Ids to the [groupId, subGroupId] path + set( + groupedIssues, + [groupId, subGroupId], + subGroupIssueResult.map((issue) => issue.id) + ); + + continue; + } + } + } + + return { issueList, groupedIssues, groupedIssueCount }; + } + + /** + * This method is used to update the grouped issue Ids to it's respected lists and also to update group Issue Counts + * @param groupedIssues Object that contains list of issueIds with respect to their groups/subgroups + * @param groupedIssueCount Object the contains the issue count of each groups + * @param groupId groupId string + * @param subGroupId subGroupId string + * @returns updates the store with the values + */ + updateGroupedIssueIds( + groupedIssues: TIssues, + groupedIssueCount: TGroupedIssueCount, + groupId?: string, + subGroupId?: string + ) { + // if groupId exists and groupedIssues has ALL_ISSUES as a group, + // then it's an individual group/subgroup pagination + if (groupId && groupedIssues[ALL_ISSUES] && Array.isArray(groupedIssues[ALL_ISSUES])) { + const issueGroup = groupedIssues[ALL_ISSUES]; + const issueGroupCount = groupedIssueCount[ALL_ISSUES]; + const issuesPath = [groupId]; + // issuesPath is the path for the issue List in the Grouped Issue List + // issuePath is either [groupId] for grouped pagination or [groupId, subGroupId] for subGrouped pagination + if (subGroupId) issuesPath.push(subGroupId); + + // update the issue Count of the particular group/subGroup + set(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], issueGroupCount); + + // update the issue list in the issuePath + this.updateIssueGroup(issueGroup, issuesPath); + return; + } + + // if not in the above condition the it's a complete grouped pagination not individual group/subgroup pagination + // update total issue count as ALL_ISSUES count in `groupedIssueCount` object + set(this.groupedIssueCount, [ALL_ISSUES], groupedIssueCount[ALL_ISSUES]); + + // loop through the groups of groupedIssues. + for (const groupId in groupedIssues) { + const issueGroup = groupedIssues[groupId]; + const issueGroupCount = groupedIssueCount[groupId]; + + // update the groupId's issue count + set(this.groupedIssueCount, [groupId], issueGroupCount); + + // This updates the group issue list in the store, if the issueGroup is a string + const storeUpdated = this.updateIssueGroup(issueGroup, [groupId]); + // if issueGroup is indeed a string, continue + if (storeUpdated) continue; + + // if issueGroup is not a string, loop through the sub group Issues + for (const subGroupId in issueGroup) { + const issueSubGroup = (issueGroup as TGroupedIssues)[subGroupId]; + const issueSubGroupCount = groupedIssueCount[this.getGroupKey(groupId, subGroupId)]; + + // update the subGroupId's issue count + set(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], issueSubGroupCount); + // This updates the subgroup issue list in the store + this.updateIssueGroup(issueSubGroup, [groupId, subGroupId]); + } + } + } + + /** + * This Method is used to update the issue Id list at the particular issuePath + * @param groupedIssueIds could be an issue Id List for grouped issues or an object that contains a issue Id list in case of subGrouped + * @param issuePath array of string, to identify the path of the issueList to be updated with the above issue Id list + * @returns a boolean that indicates if the groupedIssueIds is indeed a array Id list, in which case the issue Id list is added to the store at issuePath + */ + updateIssueGroup(groupedIssueIds: TGroupedIssues | string[], issuePath: string[]): boolean { + if (!groupedIssueIds) return true; + + // if groupedIssueIds is an array, update the `groupedIssueIds` store at the issuePath + if (groupedIssueIds && Array.isArray(groupedIssueIds)) { + update(this, ["groupedIssueIds", ...issuePath], (issueIds: string[] = []) => + uniq(concat(issueIds, groupedIssueIds)) + ); + // return true to indicate the store has been updated + return true; + } + + // return false to indicate the store has been updated and the groupedIssueIds is likely Object for subGrouped Issues + return false; + } + + /** + * This method is used to update the count of the issues at the path with the increment + * @param path issuePath, corresponding key is to be incremented + * @param increment + */ + updateIssueCount(accumulatedUpdatesForCount: { [key: string]: EIssueGroupedAction }) { + const updateKeys = Object.keys(accumulatedUpdatesForCount); + for (const updateKey of updateKeys) { + const update = accumulatedUpdatesForCount[updateKey]; + if (!update) continue; + + const increment = update === EIssueGroupedAction.ADD ? 1 : -1; + // get current count at the key + const issueCount = get(this.groupedIssueCount, updateKey) ?? 0; + // update the count at the key + set(this.groupedIssueCount, updateKey, issueCount + increment); + } + } + + /** + * This Method is called to store the pagination options and paginated data from response + * @param issuesResponse issue list response + * @param options pagination options to be stored for next page call + * @param groupId + * @param subGroupId + */ + storePreviousPaginationValues = ( + issuesResponse: TIssuesResponse, + options?: IssuePaginationOptions, + groupId?: string, + subGroupId?: string + ) => { + if (options) this.paginationOptions = options; + + this.setPaginationData( + issuesResponse.prev_cursor, + issuesResponse.next_cursor, + issuesResponse.next_page_results, + groupId, + subGroupId + ); + }; + + /** + * returns, + * A compound key, if both groupId & subGroupId are defined + * groupId, only if groupId is defined + * ALL_ISSUES, if both groupId & subGroupId are not defined + * @param groupId + * @param subGroupId + * @returns + */ + getGroupKey = (groupId?: string, subGroupId?: string) => { + if (groupId && subGroupId && subGroupId !== "null") return `${groupId}_${subGroupId}`; + + if (groupId) return groupId; + + return ALL_ISSUES; + }; +} diff --git a/plane-src/apps/space/store/helpers/filter.helpers.ts b/plane-src/apps/space/store/helpers/filter.helpers.ts new file mode 100644 index 0000000..8a0e454 --- /dev/null +++ b/plane-src/apps/space/store/helpers/filter.helpers.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { EIssueGroupByToServerOptions, EServerGroupByToFilterOptions } from "@plane/constants"; +import type { IssuePaginationOptions, TIssueParams } from "@plane/types"; + +/** + * This Method is used to construct the url params along with paginated values + * @param filterParams params generated from filters + * @param options pagination options + * @param cursor cursor if exists + * @param groupId groupId if to fetch By group + * @param subGroupId groupId if to fetch By sub group + * @returns + */ +export const getPaginationParams = ( + filterParams: Partial> | undefined, + options: IssuePaginationOptions, + cursor: string | undefined, + groupId?: string, + subGroupId?: string +) => { + // if cursor exists, use the cursor. If it doesn't exist construct the cursor based on per page count + const pageCursor = cursor ? cursor : groupId ? `${options.perPageCount}:1:0` : `${options.perPageCount}:0:0`; + + // pagination params + const paginationParams: Partial> = { + ...filterParams, + cursor: pageCursor, + per_page: options.perPageCount.toString(), + }; + + // If group by is specifically sent through options, like that for calendar layout, use that to group + if (options.groupedBy) { + paginationParams.group_by = EIssueGroupByToServerOptions[options.groupedBy]; + } + + // If group by is specifically sent through options, like that for calendar layout, use that to group + if (options.subGroupedBy) { + paginationParams.sub_group_by = EIssueGroupByToServerOptions[options.subGroupedBy]; + } + + // If group by is specifically sent through options, like that for calendar layout, use that to group + if (options.orderBy) { + paginationParams.order_by = options.orderBy; + } + + // If before and after dates are sent from option to filter by then, add them to filter the options + if (options.after && options.before) { + paginationParams["target_date"] = `${options.after};after,${options.before};before`; + } + + // If groupId is passed down, add a filter param for that group Id + if (groupId) { + const groupBy = paginationParams["group_by"] as EIssueGroupByToServerOptions | undefined; + delete paginationParams["group_by"]; + + if (groupBy) { + const groupByFilterOption = EServerGroupByToFilterOptions[groupBy]; + paginationParams[groupByFilterOption] = groupId; + } + } + + // If subGroupId is passed down, add a filter param for that subGroup Id + if (subGroupId) { + const subGroupBy = paginationParams["sub_group_by"] as EIssueGroupByToServerOptions | undefined; + delete paginationParams["sub_group_by"]; + + if (subGroupBy) { + const subGroupByFilterOption = EServerGroupByToFilterOptions[subGroupBy]; + paginationParams[subGroupByFilterOption] = subGroupId; + } + } + + return paginationParams; +}; diff --git a/plane-src/apps/space/store/instance.store.ts b/plane-src/apps/space/store/instance.store.ts new file mode 100644 index 0000000..37dc931 --- /dev/null +++ b/plane-src/apps/space/store/instance.store.ts @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { set } from "lodash-es"; +import { observable, action, makeObservable, runInAction } from "mobx"; +// plane imports +import { InstanceService } from "@plane/services"; +import type { IInstance, IInstanceConfig } from "@plane/types"; +// store +import type { RootStore } from "@/store/root.store"; + +type TError = { + status: string; + message: string; + data?: { + is_activated: boolean; + is_setup_done: boolean; + }; +}; + +export interface IInstanceStore { + // observables + isLoading: boolean; + instance: IInstance | undefined; + config: IInstanceConfig | undefined; + error: TError | undefined; + // action + fetchInstanceInfo: () => Promise; + hydrate: (data: IInstance) => void; +} + +export class InstanceStore implements IInstanceStore { + isLoading: boolean = true; + instance: IInstance | undefined = undefined; + config: IInstanceConfig | undefined = undefined; + error: TError | undefined = undefined; + // services + instanceService; + + constructor(private store: RootStore) { + makeObservable(this, { + // observable + isLoading: observable.ref, + instance: observable, + config: observable, + error: observable, + // actions + fetchInstanceInfo: action, + hydrate: action, + }); + // services + this.instanceService = new InstanceService(); + } + + hydrate = (data: IInstance) => set(this, "instance", data); + + /** + * @description fetching instance information + */ + fetchInstanceInfo = async () => { + try { + this.isLoading = true; + this.error = undefined; + const instanceInfo = await this.instanceService.info(); + runInAction(() => { + this.isLoading = false; + this.instance = instanceInfo.instance; + this.config = instanceInfo.config; + }); + } catch (_error) { + runInAction(() => { + this.isLoading = false; + this.error = { + status: "error", + message: "Failed to fetch instance info", + }; + }); + } + }; +} diff --git a/plane-src/apps/space/store/issue-detail.store.ts b/plane-src/apps/space/store/issue-detail.store.ts new file mode 100644 index 0000000..6cbb6c8 --- /dev/null +++ b/plane-src/apps/space/store/issue-detail.store.ts @@ -0,0 +1,447 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { isEmpty, set } from "lodash-es"; +import { makeObservable, observable, action, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +import { v4 as uuidv4 } from "uuid"; +// plane imports +import { SitesFileService, SitesIssueService } from "@plane/services"; +import type { TFileSignedURLResponse, TIssuePublicComment } from "@plane/types"; +import { EFileAssetType } from "@plane/types"; +// store +import type { RootStore } from "@/store/root.store"; +// types +import type { IIssue, IPeekMode, IVote } from "@/types/issue"; + +export interface IIssueDetailStore { + loader: boolean; + error: any; + // observables + peekId: string | null; + peekMode: IPeekMode; + details: { + [key: string]: IIssue; + }; + // computed actions + getIsIssuePeeked: (issueID: string) => boolean; + // actions + getIssueById: (issueId: string) => IIssue | undefined; + setPeekId: (issueID: string | null) => void; + setPeekMode: (mode: IPeekMode) => void; + // issue actions + fetchIssueDetails: (anchor: string, issueID: string) => void; + // comment actions + addIssueComment: (anchor: string, issueID: string, data: any) => Promise; + updateIssueComment: (anchor: string, issueID: string, commentID: string, data: any) => Promise; + deleteIssueComment: (anchor: string, issueID: string, commentID: string) => void; + uploadCommentAsset: (file: File, anchor: string, commentID?: string) => Promise; + uploadIssueAsset: (file: File, anchor: string, commentID?: string) => Promise; + addCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void; + removeCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void; + // reaction actions + addIssueReaction: (anchor: string, issueID: string, reactionHex: string) => void; + removeIssueReaction: (anchor: string, issueID: string, reactionHex: string) => void; + // vote actions + addIssueVote: (anchor: string, issueID: string, data: { vote: 1 | -1 }) => Promise; + removeIssueVote: (anchor: string, issueID: string) => Promise; +} + +export class IssueDetailStore implements IIssueDetailStore { + loader: boolean = false; + error: any = null; + // observables + peekId: string | null = null; + peekMode: IPeekMode = "side"; + details: { + [key: string]: IIssue; + } = {}; + // root store + rootStore: RootStore; + // services + issueService: SitesIssueService; + fileService: SitesFileService; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + loader: observable.ref, + error: observable.ref, + // observables + peekId: observable.ref, + peekMode: observable.ref, + details: observable, + // actions + setPeekId: action, + setPeekMode: action, + // issue actions + fetchIssueDetails: action, + // comment actions + addIssueComment: action, + updateIssueComment: action, + deleteIssueComment: action, + uploadCommentAsset: action, + uploadIssueAsset: action, + addCommentReaction: action, + removeCommentReaction: action, + // reaction actions + addIssueReaction: action, + removeIssueReaction: action, + // vote actions + addIssueVote: action, + removeIssueVote: action, + }); + this.rootStore = _rootStore; + this.issueService = new SitesIssueService(); + this.fileService = new SitesFileService(); + } + + setPeekId = (issueID: string | null) => { + this.peekId = issueID; + }; + + setPeekMode = (mode: IPeekMode) => { + this.peekMode = mode; + }; + + getIsIssuePeeked = (issueID: string) => this.peekId === issueID; + + /** + * @description This method will return the issue from the issuesMap + * @param {string} issueId + * @returns {IIssue | undefined} + */ + getIssueById = computedFn((issueId: string) => { + if (!issueId || isEmpty(this.details) || !this.details[issueId]) return undefined; + return this.details[issueId]; + }); + + /** + * Retrieves issue from API + * @param anchorId ] + * @param issueId + * @returns + */ + fetchIssueById = async (anchorId: string, issueId: string) => { + try { + const issueDetails = await this.issueService.retrieve(anchorId, issueId); + + runInAction(() => { + set(this.details, [issueId], issueDetails); + }); + + return issueDetails; + } catch (e) { + console.error(`Error fetching issue details for issueId ${issueId}: `, e); + } + }; + + /** + * @description fetc + * @param {string} anchor + * @param {string} issueID + */ + fetchIssueDetails = async (anchor: string, issueID: string) => { + try { + this.loader = true; + this.error = null; + + const issueDetails = await this.fetchIssueById(anchor, issueID); + const commentsResponse = await this.issueService.listComments(anchor, issueID); + + if (issueDetails) { + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...(this.details[issueID] ?? issueDetails), + comments: commentsResponse, + }, + }; + }); + } + } catch (error) { + this.loader = false; + this.error = error; + } + }; + + addIssueComment = async (anchor: string, issueID: string, data: any) => { + try { + const issueDetails = this.getIssueById(issueID); + const issueCommentResponse = await this.issueService.addComment(anchor, issueID, data); + if (issueDetails) { + runInAction(() => { + set(this.details, [issueID, "comments"], [...(issueDetails?.comments ?? []), issueCommentResponse]); + }); + } + return issueCommentResponse; + } catch (error) { + console.log("Failed to add issue comment"); + throw error; + } + }; + + updateIssueComment = async (anchor: string, issueID: string, commentID: string, data: any) => { + try { + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: this.details[issueID].comments.map((c) => ({ + ...c, + ...(c.id === commentID ? data : {}), + })), + }, + }; + }); + + await this.issueService.updateComment(anchor, issueID, commentID, data); + } catch (_error) { + const issueComments = await this.issueService.listComments(anchor, issueID); + + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: issueComments, + }, + }; + }); + } + }; + + deleteIssueComment = async (anchor: string, issueID: string, commentID: string) => { + try { + await this.issueService.removeComment(anchor, issueID, commentID); + const remainingComments = this.details[issueID].comments.filter((c) => c.id != commentID); + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: remainingComments, + }, + }; + }); + } catch (_error) { + console.log("Failed to add issue vote"); + } + }; + + uploadCommentAsset = async (file: File, anchor: string, commentID?: string) => { + try { + const res = await this.fileService.uploadAsset( + anchor, + { + entity_identifier: commentID ?? "", + entity_type: EFileAssetType.COMMENT_DESCRIPTION, + }, + file + ); + return res; + } catch (error) { + console.log("Error in uploading comment asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }; + + uploadIssueAsset = async (file: File, anchor: string, commentID?: string) => { + try { + const res = await this.fileService.uploadAsset( + anchor, + { + entity_identifier: commentID ?? "", + entity_type: EFileAssetType.ISSUE_DESCRIPTION, + }, + file + ); + return res; + } catch (error) { + console.log("Error in uploading comment asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }; + + addCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => { + const newReaction = { + id: uuidv4(), + comment: commentID, + reaction: reactionHex, + actor_detail: this.rootStore.user.currentActor, + }; + const newComments = this.details[issueID].comments.map((comment) => ({ + ...comment, + comment_reactions: + comment.id === commentID ? [...comment.comment_reactions, newReaction] : comment.comment_reactions, + })); + + try { + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: [...newComments], + }, + }; + }); + + await this.issueService.addCommentReaction(anchor, commentID, { + reaction: reactionHex, + }); + } catch (_error) { + const issueComments = await this.issueService.listComments(anchor, issueID); + + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: issueComments, + }, + }; + }); + } + }; + + removeCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => { + try { + const comment = this.details[issueID].comments.find((c) => c.id === commentID); + const newCommentReactions = comment?.comment_reactions.filter((r) => r.reaction !== reactionHex) ?? []; + + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: this.details[issueID].comments.map((c) => ({ + ...c, + comment_reactions: c.id === commentID ? newCommentReactions : c.comment_reactions, + })), + }, + }; + }); + + await this.issueService.removeCommentReaction(anchor, commentID, reactionHex); + } catch (_error) { + const issueComments = await this.issueService.listComments(anchor, issueID); + + runInAction(() => { + this.details = { + ...this.details, + [issueID]: { + ...this.details[issueID], + comments: issueComments, + }, + }; + }); + } + }; + + addIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => { + try { + runInAction(() => { + set( + this.details, + [issueID, "reaction_items"], + [ + ...this.details[issueID].reaction_items, + { + reaction: reactionHex, + actor_details: this.rootStore.user.currentActor, + }, + ] + ); + }); + + await this.issueService.addReaction(anchor, issueID, { + reaction: reactionHex, + }); + } catch (_error) { + console.log("Failed to add issue vote"); + const issueReactions = await this.issueService.listReactions(anchor, issueID); + runInAction(() => { + set(this.details, [issueID, "reaction_items"], issueReactions); + }); + } + }; + + removeIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => { + try { + const newReactions = this.details[issueID].reaction_items.filter( + (_r) => !(_r.reaction === reactionHex && _r.actor_details.id === this.rootStore.user.data?.id) + ); + + runInAction(() => { + set(this.details, [issueID, "reaction_items"], newReactions); + }); + + await this.issueService.removeReaction(anchor, issueID, reactionHex); + } catch (_error) { + console.log("Failed to remove issue reaction"); + const reactions = await this.issueService.listReactions(anchor, issueID); + runInAction(() => { + set(this.details, [issueID, "reaction_items"], reactions); + }); + } + }; + + addIssueVote = async (anchor: string, issueID: string, data: { vote: 1 | -1 }) => { + const publishSettings = this.rootStore.publishList?.publishMap?.[anchor]; + const projectID = publishSettings?.project; + const workspaceSlug = publishSettings?.workspace_detail?.slug; + if (!projectID || !workspaceSlug) throw new Error("Publish settings not found"); + + const newVote: IVote = { + actor_details: this.rootStore.user.currentActor, + vote: data.vote, + }; + + const filteredVotes = this.details[issueID].vote_items.filter( + (v) => v.actor_details?.id !== this.rootStore.user.data?.id + ); + + try { + runInAction(() => { + runInAction(() => { + set(this.details, [issueID, "vote_items"], [...filteredVotes, newVote]); + }); + }); + + await this.issueService.addVote(anchor, issueID, data); + } catch (_error) { + console.log("Failed to add issue vote"); + const issueVotes = await this.issueService.listVotes(anchor, issueID); + + runInAction(() => { + set(this.details, [issueID, "vote_items"], issueVotes); + }); + } + }; + + removeIssueVote = async (anchor: string, issueID: string) => { + const newVotes = this.details[issueID].vote_items.filter( + (v) => v.actor_details?.id !== this.rootStore.user.data?.id + ); + + try { + runInAction(() => { + set(this.details, [issueID, "vote_items"], newVotes); + }); + + await this.issueService.removeVote(anchor, issueID); + } catch (_error) { + console.log("Failed to remove issue vote"); + const issueVotes = await this.issueService.listVotes(anchor, issueID); + + runInAction(() => { + set(this.details, [issueID, "vote_items"], issueVotes); + }); + } + }; +} diff --git a/plane-src/apps/space/store/issue-filters.store.ts b/plane-src/apps/space/store/issue-filters.store.ts new file mode 100644 index 0000000..e2cb514 --- /dev/null +++ b/plane-src/apps/space/store/issue-filters.store.ts @@ -0,0 +1,164 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { cloneDeep, isEqual, set } from "lodash-es"; +import { action, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// plane internal +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@plane/constants"; +import type { IssuePaginationOptions, TIssueParams } from "@plane/types"; +// store +import type { RootStore } from "@/store/root.store"; +// types +import type { + TIssueLayoutOptions, + TIssueFilters, + TIssueQueryFilters, + TIssueQueryFiltersParams, + TIssueFilterKeys, +} from "@/types/issue"; +import { getPaginationParams } from "./helpers/filter.helpers"; + +export interface IIssueFilterStore { + // observables + layoutOptions: TIssueLayoutOptions; + filters: { [anchor: string]: TIssueFilters } | undefined; + // computed + isIssueFiltersUpdated: (anchor: string, filters: TIssueFilters) => boolean; + // helpers + getIssueFilters: (anchor: string) => TIssueFilters | undefined; + getAppliedFilters: (anchor: string) => TIssueQueryFiltersParams | undefined; + // actions + updateLayoutOptions: (layout: TIssueLayoutOptions) => void; + initIssueFilters: (anchor: string, filters: TIssueFilters, shouldFetchIssues?: boolean) => void; + updateIssueFilters: ( + anchor: string, + filterKind: K, + filterKey: keyof TIssueFilters[K], + filters: TIssueFilters[K][typeof filterKey] + ) => Promise; + getFilterParams: ( + options: IssuePaginationOptions, + anchor: string, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => Partial>; +} + +export class IssueFilterStore implements IIssueFilterStore { + // observables + layoutOptions: TIssueLayoutOptions = { + list: true, + kanban: false, + calendar: false, + gantt: false, + spreadsheet: false, + }; + filters: { [anchor: string]: TIssueFilters } | undefined = undefined; + + constructor(private store: RootStore) { + makeObservable(this, { + // observables + layoutOptions: observable, + filters: observable, + // actions + updateLayoutOptions: action, + initIssueFilters: action, + updateIssueFilters: action, + }); + } + + // helper methods + computedFilter = (filters: TIssueQueryFilters, filteredParams: TIssueFilterKeys[]) => { + const computedFilters: TIssueQueryFiltersParams = {}; + + Object.keys(filters).map((key) => { + const currentFilterKey = key as TIssueFilterKeys; + const filterValue = filters[currentFilterKey] as any; + + if (filterValue !== undefined && filteredParams.includes(currentFilterKey)) { + if (Array.isArray(filterValue)) computedFilters[currentFilterKey] = filterValue.join(","); + else if (typeof filterValue === "string" || typeof filterValue === "boolean") + computedFilters[currentFilterKey] = filterValue.toString(); + } + }); + + return computedFilters; + }; + + // computed + getIssueFilters = computedFn((anchor: string) => { + const currentFilters = this.filters?.[anchor]; + return currentFilters; + }); + + getAppliedFilters = computedFn((anchor: string) => { + const issueFilters = this.getIssueFilters(anchor); + if (!issueFilters) return undefined; + + const currentLayout = issueFilters?.display_filters?.layout; + if (!currentLayout) return undefined; + + const currentFilters: TIssueQueryFilters = { + priority: issueFilters?.filters?.priority || undefined, + state: issueFilters?.filters?.state || undefined, + labels: issueFilters?.filters?.labels || undefined, + }; + const filteredParams = ISSUE_DISPLAY_FILTERS_BY_LAYOUT?.[currentLayout]?.filters || []; + const currentFilterQueryParams: TIssueQueryFiltersParams = this.computedFilter(currentFilters, filteredParams); + + return currentFilterQueryParams; + }); + + isIssueFiltersUpdated = computedFn((anchor: string, userFilters: TIssueFilters) => { + const issueFilters = this.getIssueFilters(anchor); + if (!issueFilters) return false; + const currentUserFilters = cloneDeep(userFilters?.filters || {}); + const currentIssueFilters = cloneDeep(issueFilters?.filters || {}); + return isEqual(currentUserFilters, currentIssueFilters); + }); + + // actions + updateLayoutOptions = (options: TIssueLayoutOptions) => set(this, ["layoutOptions"], options); + + initIssueFilters = async (anchor: string, initFilters: TIssueFilters, shouldFetchIssues: boolean = false) => { + if (this.filters === undefined) runInAction(() => (this.filters = {})); + if (this.filters && initFilters) set(this.filters, [anchor], initFilters); + + if (shouldFetchIssues) await this.store.issue.fetchPublicIssuesWithExistingPagination(anchor, "mutation"); + }; + + getFilterParams = computedFn( + ( + options: IssuePaginationOptions, + anchor: string, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => { + const filterParams = this.getAppliedFilters(anchor); + const paginationParams = getPaginationParams(filterParams, options, cursor, groupId, subGroupId); + return paginationParams; + } + ); + + updateIssueFilters = async ( + anchor: string, + filterKind: K, + filterKey: keyof TIssueFilters[K], + filterValue: TIssueFilters[K][typeof filterKey] + ) => { + if (!filterKind || !filterKey || !filterValue) return; + if (this.filters === undefined) runInAction(() => (this.filters = {})); + + runInAction(() => { + if (this.filters) set(this.filters, [anchor, filterKind, filterKey], filterValue); + }); + + if (filterKey !== "layout") await this.store.issue.fetchPublicIssuesWithExistingPagination(anchor, "mutation"); + }; +} diff --git a/plane-src/apps/space/store/issue.store.ts b/plane-src/apps/space/store/issue.store.ts new file mode 100644 index 0000000..93556bc --- /dev/null +++ b/plane-src/apps/space/store/issue.store.ts @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { action, makeObservable, runInAction } from "mobx"; +// plane imports +import { SitesIssueService } from "@plane/services"; +import type { IssuePaginationOptions, TLoader } from "@plane/types"; +// store +import type { RootStore } from "@/store/root.store"; +// types +import { BaseIssuesStore } from "./helpers/base-issues.store"; +import type { IBaseIssuesStore } from "./helpers/base-issues.store"; + +export interface IIssueStore extends IBaseIssuesStore { + // actions + fetchPublicIssues: ( + anchor: string, + loadType: TLoader, + options: IssuePaginationOptions, + isExistingPaginationOptions?: boolean + ) => Promise; + fetchNextPublicIssues: (anchor: string, groupId?: string, subGroupId?: string) => Promise; + fetchPublicIssuesWithExistingPagination: (anchor: string, loadType?: TLoader) => Promise; +} + +export class IssueStore extends BaseIssuesStore implements IIssueStore { + // root store + rootStore: RootStore; + // services + issueService: SitesIssueService; + + constructor(_rootStore: RootStore) { + super(_rootStore); + makeObservable(this, { + // actions + fetchPublicIssues: action, + fetchNextPublicIssues: action, + fetchPublicIssuesWithExistingPagination: action, + }); + + this.rootStore = _rootStore; + this.issueService = new SitesIssueService(); + } + + /** + * @description fetch issues, states and labels + * @param {string} anchor + * @param params + */ + fetchPublicIssues = async ( + anchor: string, + loadType: TLoader = "init-loader", + options: IssuePaginationOptions, + isExistingPaginationOptions: boolean = false + ) => { + try { + // set loader and clear store + runInAction(() => { + this.setLoader(loadType); + }); + this.clear(!isExistingPaginationOptions); + + const params = this.rootStore.issueFilter.getFilterParams(options, anchor, undefined, undefined, undefined); + + const response = await this.issueService.list(anchor, params); + + // after fetching issues, call the base method to process the response further + this.onfetchIssues(response, options); + } catch (error) { + this.setLoader(undefined); + throw error; + } + }; + + fetchNextPublicIssues = async (anchor: string, groupId?: string, subGroupId?: string) => { + const cursorObject = this.getPaginationData(groupId, subGroupId); + // if there are no pagination options and the next page results do not exist the return + if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return; + try { + // set Loader + this.setLoader("pagination", groupId, subGroupId); + + // get params from stored pagination options + const params = this.rootStore.issueFilter.getFilterParams( + this.paginationOptions, + anchor, + cursorObject?.nextCursor, + groupId, + subGroupId + ); + // call the fetch issues API with the params for next page in issues + const response = await this.issueService.list(anchor, params); + + // after the next page of issues are fetched, call the base method to process the response + this.onfetchNexIssues(response, groupId, subGroupId); + } catch (error) { + // set Loader as undefined if errored out + this.setLoader(undefined, groupId, subGroupId); + throw error; + } + }; + + /** + * This Method exists to fetch the first page of the issues with the existing stored pagination + * This is useful for refetching when filters, groupBy, orderBy etc changes + * @param workspaceSlug + * @param projectId + * @param loadType + * @returns + */ + fetchPublicIssuesWithExistingPagination = async (anchor: string, loadType: TLoader = "mutation") => { + if (!this.paginationOptions) return; + return await this.fetchPublicIssues(anchor, loadType, this.paginationOptions, true); + }; +} diff --git a/plane-src/apps/space/store/label.store.ts b/plane-src/apps/space/store/label.store.ts new file mode 100644 index 0000000..b1c229f --- /dev/null +++ b/plane-src/apps/space/store/label.store.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { set } from "lodash-es"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesLabelService } from "@plane/services"; +import type { IIssueLabel } from "@plane/types"; +// store +import type { RootStore } from "./root.store"; + +export interface IIssueLabelStore { + // observables + labels: IIssueLabel[] | undefined; + // computed actions + getLabelById: (labelId: string | undefined) => IIssueLabel | undefined; + getLabelsByIds: (labelIds: string[]) => IIssueLabel[]; + // fetch actions + fetchLabels: (anchor: string) => Promise; +} + +export class LabelStore implements IIssueLabelStore { + labelMap: Record = {}; + labelService: SitesLabelService; + rootStore: RootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + labelMap: observable, + // computed + labels: computed, + // fetch action + fetchLabels: action, + }); + this.labelService = new SitesLabelService(); + this.rootStore = _rootStore; + } + + get labels() { + return Object.values(this.labelMap); + } + + getLabelById = (labelId: string | undefined) => (labelId ? this.labelMap[labelId] : undefined); + + getLabelsByIds = (labelIds: string[]) => { + const currLabels = []; + for (const labelId of labelIds) { + const label = this.getLabelById(labelId); + if (label) { + currLabels.push(label); + } + } + + return currLabels; + }; + + fetchLabels = async (anchor: string) => { + const labelsResponse = await this.labelService.list(anchor); + runInAction(() => { + this.labelMap = {}; + for (const label of labelsResponse) { + set(this.labelMap, [label.id], label); + } + }); + return labelsResponse; + }; +} diff --git a/plane-src/apps/space/store/members.store.ts b/plane-src/apps/space/store/members.store.ts new file mode 100644 index 0000000..a06d005 --- /dev/null +++ b/plane-src/apps/space/store/members.store.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { set } from "lodash-es"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesMemberService } from "@plane/services"; +import type { TPublicMember } from "@/types/member"; +import type { RootStore } from "./root.store"; + +export interface IIssueMemberStore { + // observables + members: TPublicMember[] | undefined; + // computed actions + getMemberById: (memberId: string | undefined) => TPublicMember | undefined; + getMembersByIds: (memberIds: string[]) => TPublicMember[]; + // fetch actions + fetchMembers: (anchor: string) => Promise; +} + +export class MemberStore implements IIssueMemberStore { + memberMap: Record = {}; + memberService: SitesMemberService; + rootStore: RootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + memberMap: observable, + // computed + members: computed, + // fetch action + fetchMembers: action, + }); + this.memberService = new SitesMemberService(); + this.rootStore = _rootStore; + } + + get members() { + return Object.values(this.memberMap); + } + + getMemberById = (memberId: string | undefined) => (memberId ? this.memberMap[memberId] : undefined); + + getMembersByIds = (memberIds: string[]) => { + const currMembers = []; + for (const memberId of memberIds) { + const member = this.getMemberById(memberId); + if (member) { + currMembers.push(member); + } + } + + return currMembers; + }; + + fetchMembers = async (anchor: string) => { + try { + const membersResponse = await this.memberService.list(anchor); + runInAction(() => { + this.memberMap = {}; + for (const member of membersResponse) { + set(this.memberMap, [member.member], member); + } + }); + return membersResponse; + } catch (error) { + console.error("Failed to fetch members:", error); + return []; + } + }; +} diff --git a/plane-src/apps/space/store/module.store.ts b/plane-src/apps/space/store/module.store.ts new file mode 100644 index 0000000..c539846 --- /dev/null +++ b/plane-src/apps/space/store/module.store.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { set } from "lodash-es"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesModuleService } from "@plane/services"; +// types +import type { TPublicModule } from "@/types/modules"; +// root store +import type { RootStore } from "./root.store"; + +export interface IIssueModuleStore { + // observables + modules: TPublicModule[] | undefined; + // computed actions + getModuleById: (moduleId: string | undefined) => TPublicModule | undefined; + getModulesByIds: (moduleIds: string[]) => TPublicModule[]; + // fetch actions + fetchModules: (anchor: string) => Promise; +} + +export class ModuleStore implements IIssueModuleStore { + moduleMap: Record = {}; + moduleService: SitesModuleService; + rootStore: RootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + moduleMap: observable, + // computed + modules: computed, + // fetch action + fetchModules: action, + }); + this.moduleService = new SitesModuleService(); + this.rootStore = _rootStore; + } + + get modules() { + return Object.values(this.moduleMap); + } + + getModuleById = (moduleId: string | undefined) => (moduleId ? this.moduleMap[moduleId] : undefined); + + getModulesByIds = (moduleIds: string[]) => { + const currModules = []; + for (const moduleId of moduleIds) { + const issueModule = this.getModuleById(moduleId); + if (issueModule) { + currModules.push(issueModule); + } + } + + return currModules; + }; + + fetchModules = async (anchor: string) => { + try { + const modulesResponse = await this.moduleService.list(anchor); + runInAction(() => { + this.moduleMap = {}; + for (const issueModule of modulesResponse) { + set(this.moduleMap, [issueModule.id], issueModule); + } + }); + return modulesResponse; + } catch (error) { + console.error("Failed to fetch members:", error); + return []; + } + }; +} diff --git a/plane-src/apps/space/store/profile.store.ts b/plane-src/apps/space/store/profile.store.ts new file mode 100644 index 0000000..d62d1d3 --- /dev/null +++ b/plane-src/apps/space/store/profile.store.ts @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { set } from "lodash-es"; +import { action, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { UserService } from "@plane/services"; +import type { TUserProfile } from "@plane/types"; +import { EStartOfTheWeek } from "@plane/types"; +// store +import type { RootStore } from "@/store/root.store"; + +type TError = { + status: string; + message: string; +}; + +export interface IProfileStore { + // observables + isLoading: boolean; + error: TError | undefined; + data: TUserProfile; + // actions + fetchUserProfile: () => Promise; + updateUserProfile: (data: Partial) => Promise; +} + +export class ProfileStore implements IProfileStore { + isLoading: boolean = false; + error: TError | undefined = undefined; + data: TUserProfile = { + id: undefined, + user: undefined, + role: undefined, + last_workspace_id: undefined, + theme: { + theme: undefined, + primary: undefined, + background: undefined, + darkPalette: undefined, + }, + onboarding_step: { + workspace_join: false, + profile_complete: false, + workspace_create: false, + workspace_invite: false, + }, + is_onboarded: false, + is_tour_completed: false, + use_case: undefined, + billing_address_country: undefined, + billing_address: undefined, + has_billing_address: false, + has_marketing_email_consent: false, + created_at: "", + updated_at: "", + language: "", + start_of_the_week: EStartOfTheWeek.SUNDAY, + }; + + // services + userService: UserService; + + constructor(public store: RootStore) { + makeObservable(this, { + // observables + isLoading: observable.ref, + error: observable, + data: observable, + // actions + fetchUserProfile: action, + updateUserProfile: action, + }); + // services + this.userService = new UserService(); + } + + // actions + /** + * @description fetches user profile information + * @returns {Promise} + */ + fetchUserProfile = async () => { + try { + runInAction(() => { + this.isLoading = true; + this.error = undefined; + }); + const userProfile = await this.userService.profile(); + runInAction(() => { + this.isLoading = false; + this.data = userProfile; + }); + return userProfile; + } catch (_error) { + runInAction(() => { + this.isLoading = false; + this.error = { + status: "user-profile-fetch-error", + message: "Failed to fetch user profile", + }; + }); + } + }; + + /** + * @description updated the user profile information + * @param {Partial} data + * @returns {Promise} + */ + updateUserProfile = async (data: Partial) => { + const currentUserProfileData = this.data; + try { + if (currentUserProfileData) { + Object.keys(data).forEach((key: string) => { + const userKey: keyof TUserProfile = key as keyof TUserProfile; + if (this.data) set(this.data, userKey, data[userKey]); + }); + } + const userProfile = await this.userService.updateProfile(data); + return userProfile; + } catch (_error) { + if (currentUserProfileData) { + Object.keys(currentUserProfileData).forEach((key: string) => { + const userKey: keyof TUserProfile = key as keyof TUserProfile; + if (this.data) set(this.data, userKey, currentUserProfileData[userKey]); + }); + } + runInAction(() => { + this.error = { + status: "user-profile-update-error", + message: "Failed to update user profile", + }; + }); + } + }; +} diff --git a/plane-src/apps/space/store/publish/publish.store.ts b/plane-src/apps/space/store/publish/publish.store.ts new file mode 100644 index 0000000..973ea86 --- /dev/null +++ b/plane-src/apps/space/store/publish/publish.store.ts @@ -0,0 +1,123 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observable, makeObservable, computed } from "mobx"; +// types +import type { + IWorkspaceLite, + TProjectDetails, + TPublishEntityType, + TProjectPublishSettings, + TProjectPublishViewProps, +} from "@plane/types"; +// store +import type { RootStore } from "../root.store"; + +export interface IPublishStore extends TProjectPublishSettings { + // computed + workspaceSlug: string | undefined; + canComment: boolean; + canReact: boolean; + canVote: boolean; +} + +export class PublishStore implements IPublishStore { + // observables + anchor: string | undefined; + is_comments_enabled: boolean; + created_at: string | undefined; + created_by: string | undefined; + entity_identifier: string | undefined; + entity_name: TPublishEntityType | undefined; + id: string | undefined; + inbox: unknown; + project: string | undefined; + project_details: TProjectDetails | undefined; + is_reactions_enabled: boolean; + updated_at: string | undefined; + updated_by: string | undefined; + view_props: TProjectPublishViewProps | undefined; + is_votes_enabled: boolean; + workspace: string | undefined; + workspace_detail: IWorkspaceLite | undefined; + + constructor( + private store: RootStore, + publishSettings: TProjectPublishSettings + ) { + this.anchor = publishSettings.anchor; + this.is_comments_enabled = publishSettings.is_comments_enabled; + this.created_at = publishSettings.created_at; + this.created_by = publishSettings.created_by; + this.entity_identifier = publishSettings.entity_identifier; + this.entity_name = publishSettings.entity_name; + this.id = publishSettings.id; + this.inbox = publishSettings.inbox; + this.project = publishSettings.project; + this.project_details = publishSettings.project_details; + this.is_reactions_enabled = publishSettings.is_reactions_enabled; + this.updated_at = publishSettings.updated_at; + this.updated_by = publishSettings.updated_by; + this.view_props = publishSettings.view_props; + this.is_votes_enabled = publishSettings.is_votes_enabled; + this.workspace = publishSettings.workspace; + this.workspace_detail = publishSettings.workspace_detail; + + makeObservable(this, { + // observables + anchor: observable.ref, + is_comments_enabled: observable.ref, + created_at: observable.ref, + created_by: observable.ref, + entity_identifier: observable.ref, + entity_name: observable.ref, + id: observable.ref, + inbox: observable, + project: observable.ref, + project_details: observable, + is_reactions_enabled: observable.ref, + updated_at: observable.ref, + updated_by: observable.ref, + view_props: observable, + is_votes_enabled: observable.ref, + workspace: observable.ref, + workspace_detail: observable, + // computed + workspaceSlug: computed, + canComment: computed, + canReact: computed, + canVote: computed, + }); + } + + /** + * @description returns the workspace slug from the workspace details + */ + get workspaceSlug() { + return this?.workspace_detail?.slug ?? undefined; + } + + /** + * @description returns whether commenting is enabled or not + */ + get canComment() { + return !!this.is_comments_enabled; + } + + /** + * @description returns whether reacting is enabled or not + */ + get canReact() { + return !!this.is_reactions_enabled; + } + + /** + * @description returns whether voting is enabled or not + */ + get canVote() { + return !!this.is_votes_enabled; + } +} diff --git a/plane-src/apps/space/store/publish/publish_list.store.ts b/plane-src/apps/space/store/publish/publish_list.store.ts new file mode 100644 index 0000000..eeebe97 --- /dev/null +++ b/plane-src/apps/space/store/publish/publish_list.store.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { set } from "lodash-es"; +import { makeObservable, observable, runInAction, action } from "mobx"; +// plane imports +import { SitesProjectPublishService } from "@plane/services"; +import type { TProjectPublishSettings } from "@plane/types"; +// store +import { PublishStore } from "@/store/publish/publish.store"; +import type { RootStore } from "@/store/root.store"; + +export interface IPublishListStore { + // observables + publishMap: Record; // anchor => PublishStore + // actions + fetchPublishSettings: (pageId: string) => Promise; +} + +export class PublishListStore implements IPublishListStore { + // observables + publishMap: Record = {}; // anchor => PublishStore + // service + publishService; + + constructor(private rootStore: RootStore) { + makeObservable(this, { + // observables + publishMap: observable, + // actions + fetchPublishSettings: action, + }); + // services + this.publishService = new SitesProjectPublishService(); + } + + /** + * @description fetch publish settings + * @param {string} anchor + */ + fetchPublishSettings = async (anchor: string) => { + const response = await this.publishService.retrieveSettingsByAnchor(anchor); + runInAction(() => { + if (response.anchor) { + set(this.publishMap, [response.anchor], new PublishStore(this.rootStore, response)); + } + }); + return response; + }; +} diff --git a/plane-src/apps/space/store/root.store.ts b/plane-src/apps/space/store/root.store.ts new file mode 100644 index 0000000..6b52618 --- /dev/null +++ b/plane-src/apps/space/store/root.store.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { enableStaticRendering } from "mobx-react"; +// store imports +import type { IInstanceStore } from "@/store/instance.store"; +import { InstanceStore } from "@/store/instance.store"; +import type { IIssueDetailStore } from "@/store/issue-detail.store"; +import { IssueDetailStore } from "@/store/issue-detail.store"; +import type { IIssueStore } from "@/store/issue.store"; +import { IssueStore } from "@/store/issue.store"; +import type { IUserStore } from "@/store/user.store"; +import { UserStore } from "@/store/user.store"; +import type { ICycleStore } from "./cycle.store"; +import { CycleStore } from "./cycle.store"; +import type { IIssueFilterStore } from "./issue-filters.store"; +import { IssueFilterStore } from "./issue-filters.store"; +import type { IIssueLabelStore } from "./label.store"; +import { LabelStore } from "./label.store"; +import type { IIssueMemberStore } from "./members.store"; +import { MemberStore } from "./members.store"; +import type { IIssueModuleStore } from "./module.store"; +import { ModuleStore } from "./module.store"; +import type { IPublishListStore } from "./publish/publish_list.store"; +import { PublishListStore } from "./publish/publish_list.store"; +import type { IStateStore } from "./state.store"; +import { StateStore } from "./state.store"; + +enableStaticRendering(typeof window === "undefined"); + +export class RootStore { + instance: IInstanceStore; + user: IUserStore; + issue: IIssueStore; + issueDetail: IIssueDetailStore; + state: IStateStore; + label: IIssueLabelStore; + module: IIssueModuleStore; + member: IIssueMemberStore; + cycle: ICycleStore; + issueFilter: IIssueFilterStore; + publishList: IPublishListStore; + + constructor() { + this.instance = new InstanceStore(this); + this.user = new UserStore(this); + this.issue = new IssueStore(this); + this.issueDetail = new IssueDetailStore(this); + this.state = new StateStore(this); + this.label = new LabelStore(this); + this.module = new ModuleStore(this); + this.member = new MemberStore(this); + this.cycle = new CycleStore(this); + this.issueFilter = new IssueFilterStore(this); + this.publishList = new PublishListStore(this); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hydrate = (data: any) => { + if (!data) return; + this.instance.hydrate(data?.instance || undefined); + this.user.hydrate(data?.user || undefined); + }; + + reset() { + localStorage.setItem("theme", "system"); + this.instance = new InstanceStore(this); + this.user = new UserStore(this); + this.issue = new IssueStore(this); + this.issueDetail = new IssueDetailStore(this); + this.state = new StateStore(this); + this.label = new LabelStore(this); + this.module = new ModuleStore(this); + this.member = new MemberStore(this); + this.cycle = new CycleStore(this); + this.issueFilter = new IssueFilterStore(this); + this.publishList = new PublishListStore(this); + } +} diff --git a/plane-src/apps/space/store/state.store.ts b/plane-src/apps/space/store/state.store.ts new file mode 100644 index 0000000..3ecf78d --- /dev/null +++ b/plane-src/apps/space/store/state.store.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { clone } from "lodash-es"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesStateService } from "@plane/services"; +import type { IState } from "@plane/types"; +// helpers +import { sortStates } from "@/helpers/state.helper"; +// store +import type { RootStore } from "./root.store"; + +export interface IStateStore { + // observables + states: IState[] | undefined; + //computed + sortedStates: IState[] | undefined; + // computed actions + getStateById: (stateId: string | undefined) => IState | undefined; + // fetch actions + fetchStates: (anchor: string) => Promise; +} + +export class StateStore implements IStateStore { + states: IState[] | undefined = undefined; + stateService: SitesStateService; + rootStore: RootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + states: observable, + // computed + sortedStates: computed, + // fetch action + fetchStates: action, + }); + this.stateService = new SitesStateService(); + this.rootStore = _rootStore; + } + + get sortedStates() { + if (!this.states) return; + return sortStates(clone(this.states)); + } + + getStateById = (stateId: string | undefined) => this.states?.find((state) => state.id === stateId); + + fetchStates = async (anchor: string) => { + const statesResponse = await this.stateService.list(anchor); + runInAction(() => { + this.states = statesResponse; + }); + return statesResponse; + }; +} diff --git a/plane-src/apps/space/store/user.store.ts b/plane-src/apps/space/store/user.store.ts new file mode 100644 index 0000000..f2a3c7d --- /dev/null +++ b/plane-src/apps/space/store/user.store.ts @@ -0,0 +1,190 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { AxiosError } from "axios"; +import { set } from "lodash-es"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { UserService } from "@plane/services"; +import type { ActorDetail, IUser } from "@plane/types"; +// store types +import type { IProfileStore } from "@/store/profile.store"; +import { ProfileStore } from "@/store/profile.store"; +// store +import type { RootStore } from "@/store/root.store"; + +type TUserErrorStatus = { + status: string; + message: string; +}; + +export interface IUserStore { + // observables + isAuthenticated: boolean; + isInitializing: boolean; + error: TUserErrorStatus | undefined; + data: IUser | undefined; + // store observables + profile: IProfileStore; + // computed + currentActor: ActorDetail; + // actions + fetchCurrentUser: () => Promise; + updateCurrentUser: (data: Partial) => Promise; + hydrate: (data: IUser | undefined) => void; + reset: () => void; + signOut: () => Promise; +} + +export class UserStore implements IUserStore { + // observables + isAuthenticated: boolean = false; + isInitializing: boolean = true; + error: TUserErrorStatus | undefined = undefined; + data: IUser | undefined = undefined; + // store observables + profile: IProfileStore; + // service + userService: UserService; + + constructor(private store: RootStore) { + // stores + this.profile = new ProfileStore(store); + // service + this.userService = new UserService(); + // observables + makeObservable(this, { + // observables + isAuthenticated: observable.ref, + isInitializing: observable.ref, + error: observable, + // model observables + data: observable, + profile: observable, + // computed + currentActor: computed, + // actions + fetchCurrentUser: action, + updateCurrentUser: action, + reset: action, + signOut: action, + }); + } + + // computed + get currentActor(): ActorDetail { + return { + id: this.data?.id, + first_name: this.data?.first_name, + last_name: this.data?.last_name, + display_name: this.data?.display_name, + avatar_url: this.data?.avatar_url || undefined, + is_bot: false, + }; + } + + // actions + /** + * @description fetches the current user + * @returns {Promise} + */ + fetchCurrentUser = async (): Promise => { + try { + runInAction(() => { + if (this.data === undefined && !this.error) this.isInitializing = true; + this.error = undefined; + }); + const user = await this.userService.me(); + if (user && user?.id) { + await this.profile.fetchUserProfile(); + runInAction(() => { + this.data = user; + this.isInitializing = false; + this.isAuthenticated = true; + }); + } else + runInAction(() => { + this.data = user; + this.isInitializing = false; + this.isAuthenticated = false; + }); + return user; + } catch (error) { + runInAction(() => { + this.isInitializing = false; + this.isAuthenticated = false; + this.error = { + status: "user-fetch-error", + message: "Failed to fetch current user", + }; + if (error instanceof AxiosError && error.status === 401) { + this.data = undefined; + } + }); + throw error; + } + }; + + /** + * @description updates the current user + * @param data + * @returns {Promise} + */ + updateCurrentUser = async (data: Partial): Promise => { + const currentUserData = this.data; + try { + if (currentUserData) { + Object.keys(data).forEach((key: string) => { + const userKey: keyof IUser = key as keyof IUser; + if (this.data) set(this.data, userKey, data[userKey]); + }); + } + const user = await this.userService.update(data); + return user; + } catch (error) { + if (currentUserData) { + Object.keys(currentUserData).forEach((key: string) => { + const userKey: keyof IUser = key as keyof IUser; + if (this.data) set(this.data, userKey, currentUserData[userKey]); + }); + } + runInAction(() => { + this.error = { + status: "user-update-error", + message: "Failed to update current user", + }; + }); + throw error; + } + }; + + hydrate = (data: IUser | undefined): void => { + if (!data) return; + this.data = { ...this.data, ...data }; + }; + + /** + * @description resets the user store + * @returns {void} + */ + reset = (): void => { + runInAction(() => { + this.isAuthenticated = false; + this.isInitializing = false; + this.error = undefined; + this.data = undefined; + this.profile = new ProfileStore(this.store); + }); + }; + + /** + * @description signs out the current user + * @returns {Promise} + */ + signOut = async (): Promise => { + this.store.reset(); + }; +} diff --git a/plane-src/apps/space/styles/globals.css b/plane-src/apps/space/styles/globals.css new file mode 100644 index 0000000..ba7f052 --- /dev/null +++ b/plane-src/apps/space/styles/globals.css @@ -0,0 +1,78 @@ +@import "@plane/tailwind-config/index.css"; +@import "@plane/editor/styles"; +@import "@plane/propel/styles/react-day-picker.css"; +@plugin "@tailwindcss/typography"; + +/* stickies and editor colors */ +:root { + /* text colors */ + --editor-colors-gray-text: #5c5e63; + --editor-colors-peach-text: #ff5b59; + --editor-colors-pink-text: #f65385; + --editor-colors-orange-text: #fd9038; + --editor-colors-green-text: #0fc27b; + --editor-colors-light-blue-text: #17bee9; + --editor-colors-dark-blue-text: #266df0; + --editor-colors-purple-text: #9162f9; + /* end text colors */ + + /* background colors */ + --editor-colors-gray-background: #d6d6d8; + --editor-colors-peach-background: #ffd5d7; + --editor-colors-pink-background: #fdd4e3; + --editor-colors-orange-background: #ffe3cd; + --editor-colors-green-background: #c3f0de; + --editor-colors-light-blue-background: #c5eff9; + --editor-colors-dark-blue-background: #c9dafb; + --editor-colors-purple-background: #e3d8fd; + /* end background colors */ +} +/* background colors */ +[data-theme*="light"] { + --editor-colors-gray-background: #d6d6d8; + --editor-colors-peach-background: #ffd5d7; + --editor-colors-pink-background: #fdd4e3; + --editor-colors-orange-background: #ffe3cd; + --editor-colors-green-background: #c3f0de; + --editor-colors-light-blue-background: #c5eff9; + --editor-colors-dark-blue-background: #c9dafb; + --editor-colors-purple-background: #e3d8fd; +} +[data-theme*="dark"] { + --editor-colors-gray-background: #404144; + --editor-colors-peach-background: #593032; + --editor-colors-pink-background: #562e3d; + --editor-colors-orange-background: #583e2a; + --editor-colors-green-background: #1d4a3b; + --editor-colors-light-blue-background: #1f495c; + --editor-colors-dark-blue-background: #223558; + --editor-colors-purple-background: #3d325a; +} +/* end background colors */ + +/* Progress Bar Styles */ +:root { + --bprogress-color: var(--background-color-accent-primary); + --bprogress-height: 2.5px !important; +} + +.bprogress { + pointer-events: none; +} + +.bprogress .bar { + background: linear-gradient( + 90deg, + --alpha(var(--background-color-accent-primary) / 80%) 0%, + --alpha(var(--background-color-accent-primary) / 100%) 100% + ) !important; + will-change: width, opacity; +} + +.bprogress .peg { + display: block; + box-shadow: + 0 0 8px --alpha(var(--background-color-accent-primary) / 60%), + 0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important; + will-change: transform, opacity; +} diff --git a/plane-src/apps/space/tsconfig.json b/plane-src/apps/space/tsconfig.json new file mode 100644 index 0000000..a340381 --- /dev/null +++ b/plane-src/apps/space/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@plane/typescript-config/react-router.json", + "compilerOptions": { + "rootDirs": [".", "./.react-router/types"], + "types": ["vite/client"], + "paths": { + "@/*": ["./*"] + }, + "strictNullChecks": true, + "noImplicitOverride": false, + "noUnusedLocals": false, + "noImplicitReturns": false, + "exactOptionalPropertyTypes": false, + "noUnusedParameters": false + }, + "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*", "additional.d.ts"], + "exclude": ["node_modules"] +} diff --git a/plane-src/apps/space/types/auth.ts b/plane-src/apps/space/types/auth.ts new file mode 100644 index 0000000..72e12cd --- /dev/null +++ b/plane-src/apps/space/types/auth.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export enum EAuthModes { + SIGN_IN = "SIGN_IN", + SIGN_UP = "SIGN_UP", +} + +export enum EAuthSteps { + EMAIL = "EMAIL", + PASSWORD = "PASSWORD", + UNIQUE_CODE = "UNIQUE_CODE", +} + +export interface ICsrfTokenData { + csrf_token: string; +} + +// email check types starts +export interface IEmailCheckData { + email: string; +} + +export interface IEmailCheckResponse { + status: "MAGIC_CODE" | "CREDENTIAL"; + is_password_autoset: boolean; + existing: boolean; +} +// email check types ends diff --git a/plane-src/apps/space/types/cycle.d.ts b/plane-src/apps/space/types/cycle.d.ts new file mode 100644 index 0000000..edf8f31 --- /dev/null +++ b/plane-src/apps/space/types/cycle.d.ts @@ -0,0 +1,5 @@ +export type TPublicCycle = { + id: string; + name: string; + status: string; +}; diff --git a/plane-src/apps/space/types/intake.d.ts b/plane-src/apps/space/types/intake.d.ts new file mode 100644 index 0000000..9cf2934 --- /dev/null +++ b/plane-src/apps/space/types/intake.d.ts @@ -0,0 +1,6 @@ +export type TIntakeIssueForm = { + name: string; + email: string; + username: string; + description_html: string; +}; diff --git a/plane-src/apps/space/types/issue.d.ts b/plane-src/apps/space/types/issue.d.ts new file mode 100644 index 0000000..299aada --- /dev/null +++ b/plane-src/apps/space/types/issue.d.ts @@ -0,0 +1,117 @@ +import type { ActorDetail, TIssue, TIssuePriorities, TStateGroups, TIssuePublicComment } from "@plane/types"; + +export type TIssueLayout = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt"; +export type TIssueLayoutOptions = { + [key in TIssueLayout]: boolean; +}; + +export type TIssueFilterPriorityObject = { + key: TIssuePriorities; + title: string; + className: string; + icon: string; +}; + +export type TIssueFilterKeys = "priority" | "state" | "labels"; + +export type TDisplayFilters = { + layout: TIssueLayout; +}; + +export type TFilters = { + state: TStateGroups[]; + priority: TIssuePriorities[]; + labels: string[]; +}; + +export type TIssueFilters = { + display_filters: TDisplayFilters; + filters: TFilters; +}; + +export type TIssueQueryFilters = Partial; + +export type TIssueQueryFiltersParams = Partial>; + +export interface IIssue extends Pick< + TIssue, + | "description_html" + | "created_at" + | "updated_at" + | "created_by" + | "id" + | "name" + | "priority" + | "state_id" + | "project_id" + | "sequence_id" + | "sort_order" + | "start_date" + | "target_date" + | "cycle_id" + | "module_ids" + | "label_ids" + | "assignee_ids" + | "attachment_count" + | "sub_issues_count" + | "link_count" + | "estimate_point" +> { + comments: TIssuePublicComment[]; + reaction_items: IIssueReaction[]; + vote_items: IVote[]; +} + +export type IPeekMode = "side" | "modal" | "full"; + +type TIssueResponseResults = + | IIssue[] + | { + [key: string]: { + results: + | IIssue[] + | { + [key: string]: { + results: IIssue[]; + total_results: number; + }; + }; + total_results: number; + }; + }; + +export type TIssuesResponse = { + grouped_by: string; + next_cursor: string; + prev_cursor: string; + next_page_results: boolean; + prev_page_results: boolean; + total_count: number; + count: number; + total_pages: number; + extra_stats: null; + results: TIssueResponseResults; +}; + +export interface IIssueLabel { + id: string; + name: string; + color: string; + parent: string | null; +} + +export interface IVote { + vote: -1 | 1; + actor_details: ActorDetail; +} + +export interface IIssueReaction { + actor_details: ActorDetail; + reaction: string; +} + +export interface IIssueFilterOptions { + state?: string[] | null; + labels?: string[] | null; + priority?: string[] | null; +} diff --git a/plane-src/apps/space/types/member.d.ts b/plane-src/apps/space/types/member.d.ts new file mode 100644 index 0000000..34c95da --- /dev/null +++ b/plane-src/apps/space/types/member.d.ts @@ -0,0 +1,6 @@ +export type TPublicMember = { + id: string; + member: string; + member__display_name: string; + member__avatar: string; +}; diff --git a/plane-src/apps/space/types/modules.d.ts b/plane-src/apps/space/types/modules.d.ts new file mode 100644 index 0000000..8bc35ce --- /dev/null +++ b/plane-src/apps/space/types/modules.d.ts @@ -0,0 +1,4 @@ +export type TPublicModule = { + id: string; + name: string; +}; diff --git a/plane-src/apps/space/vite.config.ts b/plane-src/apps/space/vite.config.ts new file mode 100644 index 0000000..1767562 --- /dev/null +++ b/plane-src/apps/space/vite.config.ts @@ -0,0 +1,39 @@ +import path from "node:path"; +import * as dotenv from "dotenv"; +import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { joinUrlPath } from "@plane/utils"; + +dotenv.config({ path: path.resolve(__dirname, ".env") }); + +// Expose only vars starting with VITE_ +const viteEnv = Object.keys(process.env) + .filter((k) => k.startsWith("VITE_")) + .reduce>((a, k) => { + a[k] = process.env[k] ?? ""; + return a; + }, {}); + +const basePath = joinUrlPath(process.env.VITE_SPACE_BASE_PATH ?? "", "/") ?? "/"; + +export default defineConfig(() => ({ + base: basePath, + define: { + "process.env": JSON.stringify(viteEnv), + }, + build: { + assetsInlineLimit: 0, + }, + plugins: [reactRouter(), tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] })], + resolve: { + alias: { + // Next.js compatibility shims used within space + "next/navigation": path.resolve(__dirname, "app/compat/next/navigation.ts"), + }, + dedupe: ["react", "react-dom"], + }, + server: { + host: "127.0.0.1", + }, +})); diff --git a/plane-src/apps/web/.dockerignore b/plane-src/apps/web/.dockerignore new file mode 100644 index 0000000..90b7c43 --- /dev/null +++ b/plane-src/apps/web/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +.react-router +.vite +.turbo +build +dist +*.log +.env* +!.env.example + diff --git a/plane-src/apps/web/.env.example b/plane-src/apps/web/.env.example new file mode 100644 index 0000000..59663aa --- /dev/null +++ b/plane-src/apps/web/.env.example @@ -0,0 +1,12 @@ +VITE_API_BASE_URL="http://localhost:8000" + +VITE_WEB_BASE_URL="http://localhost:3000" + +VITE_ADMIN_BASE_URL="http://localhost:3001" +VITE_ADMIN_BASE_PATH="/god-mode" + +VITE_SPACE_BASE_URL="http://localhost:3002" +VITE_SPACE_BASE_PATH="/spaces" + +VITE_LIVE_BASE_URL="http://localhost:3100" +VITE_LIVE_BASE_PATH="/live" diff --git a/plane-src/apps/web/.gitignore b/plane-src/apps/web/.gitignore new file mode 100644 index 0000000..7d7c7a5 --- /dev/null +++ b/plane-src/apps/web/.gitignore @@ -0,0 +1,3 @@ + +# Sentry Config File +.env.sentry-build-plugin diff --git a/plane-src/apps/web/.prettierignore b/plane-src/apps/web/.prettierignore new file mode 100644 index 0000000..b0b8bc6 --- /dev/null +++ b/plane-src/apps/web/.prettierignore @@ -0,0 +1,10 @@ +.next/ +.react-router/ +.turbo/ +.vite/ +build/ +dist/ +node_modules/ +out/ +pnpm-lock.yaml +storybook-static/ diff --git a/plane-src/apps/web/Dockerfile.dev b/plane-src/apps/web/Dockerfile.dev new file mode 100644 index 0000000..d914fd8 --- /dev/null +++ b/plane-src/apps/web/Dockerfile.dev @@ -0,0 +1,13 @@ +FROM node:22-alpine + +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + +COPY . . +RUN corepack enable pnpm && pnpm add -g turbo +RUN pnpm install + +EXPOSE 3000 +VOLUME [ "/app/node_modules", "/app/web/node_modules" ] +CMD ["pnpm", "dev", "--filter=web"] diff --git a/plane-src/apps/web/Dockerfile.web b/plane-src/apps/web/Dockerfile.web new file mode 100644 index 0000000..38af19e --- /dev/null +++ b/plane-src/apps/web/Dockerfile.web @@ -0,0 +1,88 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-alpine AS base + +# Setup pnpm package manager with corepack and configure global bin directory for caching +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +# ***************************************************************************** +# STAGE 1: Build the project +# ***************************************************************************** +FROM base AS builder +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + +ARG TURBO_VERSION=2.9.4 +RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION} +COPY . . + +RUN turbo prune --scope=web --docker + +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** +# Add lockfile and package.json's of isolated subworkspace +FROM base 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/pnpm-lock.yaml ./pnpm-lock.yaml +RUN corepack enable pnpm + +# Copy full directory structure before fetch to ensure all package.json files are available +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +# Fetch dependencies to cache store, then install offline with dev deps +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store + +ARG VITE_API_BASE_URL="" +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL + +ARG VITE_ADMIN_BASE_URL="" +ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL + +ARG VITE_ADMIN_BASE_PATH="/god-mode" +ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_BASE_PATH + +ARG VITE_LIVE_BASE_URL="" +ENV VITE_LIVE_BASE_URL=$VITE_LIVE_BASE_URL + +ARG VITE_LIVE_BASE_PATH="/live" +ENV VITE_LIVE_BASE_PATH=$VITE_LIVE_BASE_PATH + +ARG VITE_SPACE_BASE_URL="" +ENV VITE_SPACE_BASE_URL=$VITE_SPACE_BASE_URL + +ARG VITE_SPACE_BASE_PATH="/spaces" +ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH + +ARG VITE_WEB_BASE_URL="" +ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 + +RUN pnpm turbo run build --filter=web + +# ***************************************************************************** +# STAGE 3: Serve with nginx +# ***************************************************************************** +FROM nginx:1.27-alpine AS production + +COPY apps/web/nginx/nginx.conf /etc/nginx/nginx.conf +COPY --from=installer /app/apps/web/build/client /usr/share/nginx/html + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx new file mode 100644 index 0000000..173b1fb --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { useParams, usePathname } from "next/navigation"; +import { SIDEBAR_WIDTH } from "@plane/constants"; +import { useLocalStorage } from "@plane/hooks"; +// components +import { ResizableSidebar } from "@/components/sidebar/resizable-sidebar"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +// local imports +import { ExtendedAppSidebar } from "./extended-sidebar"; +import { AppSidebar } from "./sidebar"; + +export const ProjectAppSidebar = observer(function ProjectAppSidebar() { + // store hooks + const { + sidebarCollapsed, + toggleSidebar, + sidebarPeek, + toggleSidebarPeek, + isExtendedSidebarOpened, + isAnySidebarDropdownOpen, + } = useAppTheme(); + const { storedValue, setValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH); + // states + const [sidebarWidth, setSidebarWidth] = useState(storedValue ?? SIDEBAR_WIDTH); + // routes + const { workspaceSlug } = useParams(); + const pathname = usePathname(); + // derived values + const isAnyExtendedSidebarOpen = isExtendedSidebarOpened; + + const isNotificationsPath = pathname.includes(`/${workspaceSlug}/notifications`); + + // handlers + const handleWidthChange = (width: number) => setValue(width); + + if (isNotificationsPath) return null; + + return ( + <> + + + + } + isAnyExtendedSidebarExpanded={isAnyExtendedSidebarOpen} + isAnySidebarDropdownOpen={isAnySidebarDropdownOpen} + > + + + + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx new file mode 100644 index 0000000..82fe31c --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// ui +import { CycleIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +// plane web components +import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge"; + +export const WorkspaceActiveCycleHeader = observer(function WorkspaceActiveCycleHeader() { + const { t } = useTranslation(); + return ( +
+ + + } + /> + } + /> + + + +
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx new file mode 100644 index 0000000..fb00022 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import { Outlet } from "react-router"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local imports +import { WorkspaceActiveCycleHeader } from "./header"; + +export default function WorkspaceActiveCycleLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx new file mode 100644 index 0000000..222fa03 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// components +import { PageHead } from "@/components/core/page-title"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +// plane web components +import { WorkspaceActiveCyclesRoot } from "@/plane-web/components/active-cycles"; + +function WorkspaceActiveCyclesPage() { + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Active Cycles` : undefined; + + return ( + <> + + + + ); +} + +export default observer(WorkspaceActiveCyclesPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx new file mode 100644 index 0000000..ed2c921 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +import { AnalyticsIcon } from "@plane/propel/icons"; +// plane imports +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; + +export const WorkspaceAnalyticsHeader = observer(function WorkspaceAnalyticsHeader() { + const { t } = useTranslation(); + return ( +
+ + + } + /> + } + /> + + +
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx new file mode 100644 index 0000000..e123660 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import { Outlet } from "react-router"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { WorkspaceAnalyticsHeader } from "./header"; + +export default function WorkspaceAnalyticsTabLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx new file mode 100644 index 0000000..6332135 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState, useEffect } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +// plane package imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EmptyStateDetailed } from "@plane/propel/empty-state"; +import { Tabs } from "@plane/propel/tabs"; +// components +import { cn } from "@plane/utils"; +import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions"; +import { PageHead } from "@/components/core/page-title"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useProject } from "@/hooks/store/use-project"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAnalyticsTabs } from "@/plane-web/components/analytics/use-analytics-tabs"; +import type { Route } from "./+types/page"; + +function AnalyticsPage({ params }: Route.ComponentProps) { + const { tabId } = params; + + // hooks + const router = useRouter(); + + // plane imports + const { t } = useTranslation(); + + // store hooks + const { toggleCreateProjectModal } = useCommandPalette(); + const { workspaceProjectIds, loader } = useProject(); + const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); + + const pageTitle = currentWorkspace?.name + ? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name }) + : undefined; + + // permissions + const canPerformEmptyStateActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const workspaceSlug = params.workspaceSlug; + const ANALYTICS_TABS = useAnalyticsTabs(workspaceSlug.toString()); + + const [selectedTab, setSelectedTab] = useState(tabId || ANALYTICS_TABS[0]?.key); + + useEffect(() => { + if (tabId) { + setSelectedTab(tabId); + } + }, [tabId]); + + // Handle tab change + const handleTabChange = (value: string) => { + setSelectedTab(value); + router.push(`/${currentWorkspace?.slug}/analytics/${value}`); + }; + + return ( + <> + + {workspaceProjectIds && ( + <> + {workspaceProjectIds.length > 0 || loader === "init-loader" ? ( +
+ +
+
+ + {ANALYTICS_TABS.map((tab) => ( + { + if (!tab.isDisabled) { + handleTabChange(tab.key); + } + }} + > + {tab.label} + + ))} + + +
+ +
+
+ {ANALYTICS_TABS.map((tab) => ( + + + + ))} +
+
+
+ ) : ( + { + toggleCreateProjectModal(true); + }, + disabled: !canPerformEmptyStateActions, + }, + ]} + /> + )} + + )} + + ); +} + +export default observer(AnalyticsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx new file mode 100644 index 0000000..88df260 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { Header, Row } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { TabNavigationRoot } from "@/components/navigation"; +import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences"; +// local components +import { WorkItemDetailsHeader } from "./work-item-header"; + +export const ProjectWorkItemDetailsHeader = observer(function ProjectWorkItemDetailsHeader() { + // router + const { workspaceSlug, workItem } = useParams(); + // store hooks + const { sidebarCollapsed } = useAppTheme(); + const { + issue: { getIssueById, getIssueIdByIdentifier }, + } = useIssueDetail(); + // derived values + const issueId = getIssueIdByIdentifier(workItem?.toString()); + const issueDetails = issueId ? getIssueById(issueId?.toString()) : undefined; + // preferences + const { preferences: projectPreferences } = useProjectNavigationPreferences(); + + return ( + <> + {projectPreferences.navigationMode === "TABBED" && ( +
+ +
+
+ {sidebarCollapsed && ( +
+ +
+ )} +
+ + + +
+
+
+
+
+ )} + } /> + + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx new file mode 100644 index 0000000..b8b8a58 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import { Outlet } from "react-router"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectWorkItemDetailsHeader } from "./header"; + +export default function ProjectIssueDetailsLayout() { + return ( + <> + + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx new file mode 100644 index 0000000..f2c5aea --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import type { TIssue } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; +import { Loader } from "@plane/ui"; +// assets +import emptyIssueDark from "@/app/assets/empty-state/search/issues-dark.webp?url"; +import emptyIssueLight from "@/app/assets/empty-state/search/issues-light.webp?url"; +// components +import { EmptyState } from "@/components/common/empty-state"; +import { PageHead } from "@/components/core/page-title"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +// layouts +import { ProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper"; +// plane web imports +import { useWorkItemProperties } from "@/plane-web/hooks/use-issue-properties"; +import { WorkItemDetailRoot } from "@/plane-web/components/browse/workItem-detail"; + +import type { Route } from "./+types/page"; + +export const IssueDetailsPage = observer(function IssueDetailsPage({ params }: Route.ComponentProps) { + // router + const router = useAppRouter(); + const { workspaceSlug, workItem } = params; + // hooks + const { resolvedTheme } = useTheme(); + // store hooks + const { t } = useTranslation(); + const { + fetchIssueWithIdentifier, + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById, getProjectByIdentifier } = useProject(); + const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme(); + + const [projectIdentifier, sequence_id] = workItem.split("-"); + + // fetching issue details + const { data, isLoading, error } = useSWR( + `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}`, + () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) + ); + + // derived values + const projectDetails = getProjectByIdentifier(projectIdentifier); + const issueId = data?.id; + const projectId = data?.project_id ?? projectDetails?.id ?? ""; + const issue = getIssueById(issueId?.toString() || "") || undefined; + const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined; + const issueLoader = !issue || isLoading; + const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; + + useWorkItemProperties( + projectId, + workspaceSlug.toString(), + issueId, + issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES + ); + + useEffect(() => { + const handleToggleIssueDetailSidebar = () => { + if (window && window.innerWidth < 768) { + toggleIssueDetailSidebar(true); + } + if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) { + toggleIssueDetailSidebar(false); + } + }; + window.addEventListener("resize", handleToggleIssueDetailSidebar); + handleToggleIssueDetailSidebar(); + return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar); + }, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]); + + useEffect(() => { + if (data?.is_intake) { + router.push(`/${workspaceSlug}/projects/${data.project_id}/intake/?currentTab=open&inboxIssueId=${data?.id}`); + } + }, [workspaceSlug, data, router]); + + if (error && !isLoading) { + return ( + router.push(`/${workspaceSlug}/workspace-views/all-issues/`), + }} + /> + ); + } + + if (issueLoader) { + return ( + +
+ + + + +
+
+ + + + +
+
+ ); + } + + return ( + <> + + {workspaceSlug && projectId && issueId && ( + + + + )} + + ); +}); + +export default IssueDetailsPage; diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/work-item-header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/work-item-header.tsx new file mode 100644 index 0000000..8e06dd8 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/work-item-header.tsx @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane ui +import { useTranslation } from "@plane/i18n"; +import { WorkItemsIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const WorkItemDetailsHeader = observer(function WorkItemDetailsHeader() { + // router + const router = useAppRouter(); + const { workspaceSlug, workItem } = useParams(); + // store hooks + const { getProjectById, loader } = useProject(); + const { + issue: { getIssueById, getIssueIdByIdentifier }, + } = useIssueDetail(); + const { t } = useTranslation(); + // derived values + const issueId = getIssueIdByIdentifier(workItem?.toString()); + const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined; + const projectId = issueDetails ? issueDetails?.project_id : undefined; + const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined; + + if (!workspaceSlug || !projectId || !issueId) return null; + return ( +
+ + + + } + /> + } + /> + + } + /> + + + + {projectId && issueId && ( + + )} + +
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx new file mode 100644 index 0000000..a063df5 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button } from "@plane/propel/button"; +import { DraftIcon } from "@plane/propel/icons"; +import { EIssuesStoreType } from "@plane/types"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { CountChip } from "@/components/common/count-chip"; +import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal"; + +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useWorkspaceDraftIssues } from "@/hooks/store/workspace-draft"; + +export const WorkspaceDraftHeader = observer(function WorkspaceDraftHeader() { + // state + const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false); + // store hooks + const { allowPermissions } = useUserPermissions(); + const { paginationInfo } = useWorkspaceDraftIssues(); + const { joinedProjectIds } = useProject(); + + const { t } = useTranslation(); + // check if user is authorized to create draft work item + const isAuthorizedUser = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + return ( + <> + setIsDraftIssueModalOpen(false)} + isDraft + /> +
+ +
+ + } /> + } + /> + + {paginationInfo?.total_count && paginationInfo?.total_count > 0 ? ( + + ) : ( + <> + )} +
+
+ + + {joinedProjectIds && joinedProjectIds.length > 0 && ( + + )} + +
+ + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx new file mode 100644 index 0000000..d7df53f --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import { Outlet } from "react-router"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local imports +import { WorkspaceDraftHeader } from "./header"; + +export default function WorkspaceDraftLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx new file mode 100644 index 0000000..300fbce --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import { PageHead } from "@/components/core/page-title"; +import { WorkspaceDraftIssuesRoot } from "@/components/issues/workspace-draft"; +import type { Route } from "./+types/page"; + +function WorkspaceDraftPage({ params }: Route.ComponentProps) { + const { workspaceSlug } = params; + const pageTitle = "Workspace Draft"; + + return ( + <> + +
+ +
+ + ); +} + +export default WorkspaceDraftPage; diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx new file mode 100644 index 0000000..348559c --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx @@ -0,0 +1,172 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import { PlusIcon, SearchIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Tooltip } from "@plane/propel/tooltip"; +import { copyUrlToClipboard, orderJoinedProjects } from "@plane/utils"; +// components +import { CreateProjectModal } from "@/components/project/create-project-modal"; +import { SidebarProjectsListItem } from "@/components/workspace/sidebar/projects-list-item"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import type { TProject } from "@/plane-web/types"; +import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper"; + +export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar() { + // refs + const extendedProjectSidebarRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(""); + // states + const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); + // routers + const { workspaceSlug } = useParams(); + // store hooks + const { t } = useTranslation(); + const { isExtendedProjectSidebarOpened, toggleExtendedProjectSidebar } = useAppTheme(); + const { getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject(); + const { allowPermissions } = useUserPermissions(); + + const handleOnProjectDrop = ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropAtEnd: boolean + ) => { + if (!sourceId || !destinationId || !workspaceSlug) return; + if (sourceId === destinationId) return; + + const joinedProjectsList: TProject[] = []; + joinedProjects.map((projectId) => { + const projectDetails = getPartialProjectById(projectId); + if (projectDetails) joinedProjectsList.push(projectDetails); + }); + + const sourceIndex = joinedProjects.indexOf(sourceId); + const destinationIndex = shouldDropAtEnd ? joinedProjects.length : joinedProjects.indexOf(destinationId); + + if (joinedProjectsList.length <= 0) return; + + const updatedSortOrder = orderJoinedProjects(sourceIndex, destinationIndex, sourceId, joinedProjectsList); + if (updatedSortOrder != undefined) + updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("something_went_wrong"), + }); + }); + }; + + // filter projects based on search query + const filteredProjects = joinedProjects.filter((projectId) => { + const project = getPartialProjectById(projectId); + if (!project) return false; + return project.name.toLowerCase().includes(searchQuery.toLowerCase()) || project.identifier.includes(searchQuery); + }); + + // auth + const isAuthorizedUser = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const handleClose = useCallback(() => toggleExtendedProjectSidebar(false), [toggleExtendedProjectSidebar]); + + const handleCopyText = (projectId: string) => { + copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("link_copied"), + message: t("project_link_copied_to_clipboard"), + }); + }); + }; + return ( + <> + {workspaceSlug && ( + setIsProjectModalOpen(false)} + setToFavorite={false} + workspaceSlug={workspaceSlug.toString()} + /> + )} + +
+
+ Projects + {isAuthorizedUser && ( + + + + )} +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ {filteredProjects.length === 0 ? ( +
+ +
+ ) : ( +
+ {filteredProjects.map((projectId, index) => ( + handleCopyText(projectId)} + projectListType={"JOINED"} + disableDrag={false} + disableDrop={false} + isLastChild={index === filteredProjects.length - 1} + handleOnProjectDrop={handleOnProjectDrop} + renderInExtendedSidebar + /> + ))} +
+ )} +
+ + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx new file mode 100644 index 0000000..561c21e --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useEffect } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { EXTENDED_SIDEBAR_WIDTH, SIDEBAR_WIDTH } from "@plane/constants"; +import { useLocalStorage } from "@plane/hooks"; +import { cn } from "@plane/utils"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +// hooks +import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click"; + +type Props = { + className?: string; + children: React.ReactNode; + extendedSidebarRef: React.RefObject; + isExtendedSidebarOpened: boolean; + handleClose: () => void; + excludedElementId: string; +}; + +export const ExtendedSidebarWrapper = observer(function ExtendedSidebarWrapper(props: Props) { + const { className, children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props; + // store hooks + const { sidebarCollapsed } = useAppTheme(); + // local storage + const { storedValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH); + + useExtendedSidebarOutsideClickDetector(extendedSidebarRef, handleClose, excludedElementId); + + useEffect(() => { + if (sidebarCollapsed) { + handleClose(); + } + }, [sidebarCollapsed, handleClose]); + + return ( +
+ {children} +
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx new file mode 100644 index 0000000..0b8ad77 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx @@ -0,0 +1,135 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useMemo, useRef } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, EUserPermissionsLevel } from "@plane/constants"; +import type { EUserWorkspaceRoles } from "@plane/types"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences"; +// plane-web imports +import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar/extended-sidebar-item"; +import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper"; + +export const ExtendedAppSidebar = observer(function ExtendedAppSidebar() { + // refs + const extendedSidebarRef = useRef(null); + // routers + const { workspaceSlug } = useParams(); + // store hooks + const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme(); + const { allowPermissions } = useUserPermissions(); + const { preferences: workspacePreferences, updateWorkspaceItemSortOrder } = useWorkspaceNavigationPreferences(); + + // derived values + const currentWorkspaceNavigationPreferences = workspacePreferences.items; + + const sortedNavigationItems = useMemo(() => { + const slug = workspaceSlug.toString(); + + return WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.filter((item) => { + // Permission check + const hasPermission = allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug); + + return hasPermission; + }) + .map((item) => { + const preference = currentWorkspaceNavigationPreferences?.[item.key]; + return { + ...item, + sort_order: preference?.sort_order ?? 0, + is_pinned: preference?.is_pinned ?? false, + }; + }) + .sort((a, b) => { + // First sort by pinned status (pinned items first) + if (a.is_pinned !== b.is_pinned) { + return b.is_pinned ? 1 : -1; + } + // Then sort by sort_order within each group + return a.sort_order - b.sort_order; + }); + }, [workspaceSlug, currentWorkspaceNavigationPreferences, allowPermissions]); + + const sortedNavigationItemsKeys = sortedNavigationItems.map((item) => item.key); + + const orderNavigationItem = ( + sourceIndex: number, + destinationIndex: number, + navigationList: { + sort_order: number; + key: string; + labelTranslationKey: string; + href: string; + access: EUserWorkspaceRoles[]; + }[] + ): number | undefined => { + if (sourceIndex < 0 || destinationIndex < 0 || navigationList.length <= 0) return undefined; + + let updatedSortOrder: number | undefined = undefined; + const sortOrderDefaultValue = 10000; + + if (destinationIndex === 0) { + // updating project at the top of the project + const currentSortOrder = navigationList[destinationIndex].sort_order || 0; + updatedSortOrder = currentSortOrder - sortOrderDefaultValue; + } else if (destinationIndex === navigationList.length) { + // updating project at the bottom of the project + const currentSortOrder = navigationList[destinationIndex - 1].sort_order || 0; + updatedSortOrder = currentSortOrder + sortOrderDefaultValue; + } else { + // updating project in the middle of the project + const destinationTopProjectSortOrder = navigationList[destinationIndex - 1].sort_order || 0; + const destinationBottomProjectSortOrder = navigationList[destinationIndex].sort_order || 0; + const updatedValue = (destinationTopProjectSortOrder + destinationBottomProjectSortOrder) / 2; + updatedSortOrder = updatedValue; + } + + return updatedSortOrder; + }; + + const handleOnNavigationItemDrop = ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropAtEnd: boolean + ) => { + if (!sourceId || !destinationId || !workspaceSlug) return; + if (sourceId === destinationId) return; + + const sourceIndex = sortedNavigationItemsKeys.indexOf(sourceId); + const destinationIndex = shouldDropAtEnd + ? sortedNavigationItemsKeys.length + : sortedNavigationItemsKeys.indexOf(destinationId); + + const updatedSortOrder = orderNavigationItem(sourceIndex, destinationIndex, sortedNavigationItems); + + if (updatedSortOrder != undefined) updateWorkspaceItemSortOrder(sourceId, updatedSortOrder); + }; + + const handleClose = useCallback(() => toggleExtendedSidebar(false), [toggleExtendedSidebar]); + + return ( + + {sortedNavigationItems.map((item, index) => ( + + ))} + + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx new file mode 100644 index 0000000..f15d767 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { Shapes } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { HomeIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +// hooks +import { useHome } from "@/hooks/store/use-home"; + +export const WorkspaceDashboardHeader = observer(function WorkspaceDashboardHeader() { + // plane hooks + const { t } = useTranslation(); + // hooks + const { toggleWidgetSettings } = useHome(); + + return ( + <> +
+ +
+ + } /> + } + /> + +
+
+ + + +
+ + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx new file mode 100644 index 0000000..090eefb --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { Outlet } from "react-router"; +import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider"; +// plane web components +import { ProjectAppSidebar } from "./_sidebar"; +import { ExtendedProjectSidebar } from "./extended-project-sidebar"; + +function WorkspaceLayout() { + return ( + <> + +
+
+
+ + +
+ +
+
+
+ + ); +} + +export default observer(WorkspaceLayout); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx new file mode 100644 index 0000000..5188dfd --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { NotificationsSidebarRoot } from "@/components/workspace-notifications/sidebar"; + +export default function ProjectInboxIssuesLayout() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx new file mode 100644 index 0000000..ad99b55 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { PageHead } from "@/components/core/page-title"; +import { NotificationsRoot } from "@/components/workspace-notifications"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +import type { Route } from "./+types/page"; + +function WorkspaceDashboardPage({ params }: Route.ComponentProps) { + const { workspaceSlug } = params; + // plane hooks + const { t } = useTranslation(); + // hooks + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name + ? t("notification.page_label", { workspace: currentWorkspace?.name }) + : undefined; + + return ( + <> + + + + ); +} + +export default observer(WorkspaceDashboardPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx new file mode 100644 index 0000000..4d4547a --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// components +import { useTranslation } from "@plane/i18n"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { PageHead } from "@/components/core/page-title"; +import { WorkspaceHomeView } from "@/components/home"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +// local components +import { WorkspaceDashboardHeader } from "./header"; + +function WorkspaceDashboardPage() { + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - ${t("home.title")}` : undefined; + + return ( + <> + } /> + + + + + + ); +} + +export default observer(WorkspaceDashboardPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx new file mode 100644 index 0000000..1f1fe4c --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ProfileIssuesPage } from "@/components/profile/profile-issues"; +import type { Route } from "./+types/page"; + +const ProfilePageHeader = { + assigned: "Profile - Assigned", + created: "Profile - Created", + subscribed: "Profile - Subscribed", +}; + +function isValidProfileViewId(viewId: string): viewId is keyof typeof ProfilePageHeader { + return viewId in ProfilePageHeader; +} + +function ProfileIssuesTypePage({ params }: Route.ComponentProps) { + const { profileViewId } = params; + + if (!isValidProfileViewId(profileViewId)) return null; + + const header = ProfilePageHeader[profileViewId]; + + return ( + <> + + + + ); +} + +export default ProfileIssuesTypePage; diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx new file mode 100644 index 0000000..69e02a4 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +// components +import { PageHead } from "@/components/core/page-title"; +import { DownloadActivityButton } from "@/components/profile/activity/download-button"; +import { WorkspaceActivityListPage } from "@/components/profile/activity/workspace-activity-list"; +// hooks +import { useUserPermissions } from "@/hooks/store/user"; + +const PER_PAGE = 100; + +function ProfileActivityPage() { + // states + const [pageCount, setPageCount] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [resultsCount, setResultsCount] = useState(0); + // router + const { allowPermissions } = useUserPermissions(); + //hooks + const { t } = useTranslation(); + + const updateTotalPages = (count: number) => setTotalPages(count); + + const updateResultsCount = (count: number) => setResultsCount(count); + + const handleLoadMore = () => setPageCount((prev) => prev + 1); + + const activityPages: React.ReactNode[] = []; + for (let i = 0; i < pageCount; i++) + activityPages.push( + + ); + + const canDownloadActivity = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + return ( + <> + +
+
+

{t("profile.stats.recent_activity.title")}

+ {canDownloadActivity && } +
+
+ {activityPages} + {pageCount < totalPages && resultsCount !== 0 && ( +
+ +
+ )} +
+
+ + ); +} + +export default observer(ProfileActivityPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx new file mode 100644 index 0000000..296b780 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// ui +import { observer } from "mobx-react"; +import { useParams, useRouter } from "next/navigation"; +import { PanelRight } from "lucide-react"; +import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { YourWorkIcon, ChevronDownIcon } from "@plane/propel/icons"; +import type { IUserProfileProjectSegregation } from "@plane/types"; +import { Breadcrumbs, Header, CustomMenu } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { ProfileIssuesFilter } from "@/components/profile/profile-issues-filter"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useUser, useUserPermissions } from "@/hooks/store/user"; +import { Button } from "@plane/propel/button"; + +type TUserProfileHeader = { + userProjectsData: IUserProfileProjectSegregation | undefined; + type?: string | undefined; + showProfileIssuesFilter?: boolean; +}; + +export const UserProfileHeader = observer(function UserProfileHeader(props: TUserProfileHeader) { + const { userProjectsData, type = undefined, showProfileIssuesFilter } = props; + // router + const { workspaceSlug, userId } = useParams(); + const router = useRouter(); + // store hooks + const { toggleProfileSidebar, profileSidebarCollapsed } = useAppTheme(); + const { data: currentUser } = useUser(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); + // derived values + const isAuthorized = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + if (!workspaceUserInfo) return null; + + const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; + + const userName = `${userProjectsData?.user_data?.first_name} ${userProjectsData?.user_data?.last_name}`; + + const isCurrentUser = currentUser?.id === userId; + + const breadcrumbLabel = isCurrentUser ? t("profile.page_label") : `${userName} ${t("profile.work")}`; + + return ( +
+ + + } + /> + } + /> + + + +
{showProfileIssuesFilter && }
+
+ + {type} + +
+ } + customButtonClassName="flex flex-grow justify-center text-secondary text-13" + closeOnSelect + > + <> + {tabsList.map((tab) => ( + router.push(`/${workspaceSlug}/profile/${userId}/${tab.route}`)} + > + {t(tab.i18n_label)} + + ))} + +
+ +
+
+ + + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx new file mode 100644 index 0000000..a07a9e3 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +import { Outlet } from "react-router"; +import useSWR from "swr"; +// components +import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProfileSidebar } from "@/components/profile/sidebar"; +// constants +import { USER_PROFILE_PROJECT_SEGREGATION } from "@/constants/fetch-keys"; +// hooks +import { useUserPermissions } from "@/hooks/store/user"; +import useSize from "@/hooks/use-window-size"; +// local components +import { UserService } from "@/services/user.service"; +import type { Route } from "./+types/layout"; +import { UserProfileHeader } from "./header"; +import { ProfileIssuesMobileHeader } from "./mobile-header"; +import { ProfileNavbar } from "./navbar"; + +const userService = new UserService(); + +function UseProfileLayout({ params }: Route.ComponentProps) { + // router + const { workspaceSlug, userId } = params; + const pathname = usePathname(); + // store hooks + const { allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); + // derived values + const isAuthorized = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const windowSize = useSize(); + const isSmallerScreen = windowSize[0] >= 768; + + const { data: userProjectsData } = useSWR(USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug, userId), () => + userService.getUserProfileProjectsSegregation(workspaceSlug, userId) + ); + // derived values + const isAuthorizedPath = + pathname.includes("assigned") || pathname.includes("created") || pathname.includes("subscribed"); + const isIssuesTab = pathname.includes("assigned") || pathname.includes("created") || pathname.includes("subscribed"); + + const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; + const currentTab = tabsList.find((tab) => pathname === `/${workspaceSlug}/profile/${userId}${tab.selected}`); + + return ( + <> + {/* Passing the type prop from the current route value as we need the header as top most component. + TODO: We are depending on the route path to handle the mobile header type. If the path changes, this logic will break. */} +
+
+ + } + mobileHeader={isIssuesTab && } + /> + +
+
+ + {isAuthorized || !isAuthorizedPath ? ( +
+ +
+ ) : ( +
+ {t("you_do_not_have_the_permission_to_access_this_page")} +
+ )} +
+ {!isSmallerScreen && } +
+
+
+ {isSmallerScreen && } +
+ + ); +} + +export default observer(UseProfileLayout); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx new file mode 100644 index 0000000..0b9dded --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane constants +import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +// plane i18n +import { useTranslation } from "@plane/i18n"; +// icons +import { ChevronDownIcon } from "@plane/propel/icons"; +// types +import type { + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + TIssueLayouts, + EIssueLayoutTypes, +} from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; +// ui +import { CustomMenu } from "@plane/ui"; +// components +import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon"; +// hooks +import { useIssues } from "@/hooks/store/use-issues"; + +export const ProfileIssuesMobileHeader = observer(function ProfileIssuesMobileHeader() { + // plane i18n + const { t } = useTranslation(); + // router + const { workspaceSlug, userId } = useParams(); + // store hook + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROFILE); + // derived values + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: TIssueLayouts) => { + if (!workspaceSlug || !userId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout as EIssueLayoutTypes | undefined }, + userId.toString() + ); + }, + [workspaceSlug, updateFilters, userId] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !userId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + userId.toString() + ); + }, + [workspaceSlug, updateFilters, userId] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !userId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_PROPERTIES, + property, + userId.toString() + ); + }, + [workspaceSlug, updateFilters, userId] + ); + + return ( +
+ + {t("common.layout")} + +
+ } + customButtonClassName="flex flex-center text-secondary text-13" + closeOnSelect + > + {ISSUE_LAYOUTS.map((layout, index) => { + if (layout.key === "spreadsheet" || layout.key === "gantt_chart" || layout.key === "calendar") return; + return ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{t(layout.i18n_title)}
+
+ ); + })} + +
+ + {t("common.display")} + +
+ } + > + + +
+
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx new file mode 100644 index 0000000..4d99983 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +// plane imports +import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Header, EHeaderVariant } from "@plane/ui"; +import { cn } from "@plane/utils"; + +type Props = { + isAuthorized: boolean; +}; + +export function ProfileNavbar(props: Props) { + const { isAuthorized } = props; + const { t } = useTranslation(); + const { workspaceSlug, userId } = useParams(); + const pathname = usePathname(); + + const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; + + return ( +
+
+ {tabsList.map((tab) => ( + + + {t(tab.i18n_label)} + + + ))} +
+
+ ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx new file mode 100644 index 0000000..0fac006 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import useSWR from "swr"; +// plane imports +import { GROUP_CHOICES } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { IUserStateDistribution, TStateGroups } from "@plane/types"; +import { ContentWrapper } from "@plane/ui"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ProfileActivity } from "@/components/profile/overview/activity"; +import { ProfilePriorityDistribution } from "@/components/profile/overview/priority-distribution"; +import { ProfileStateDistribution } from "@/components/profile/overview/state-distribution"; +import { ProfileStats } from "@/components/profile/overview/stats"; +import { ProfileWorkload } from "@/components/profile/overview/workload"; +// constants +import { USER_PROFILE_DATA } from "@/constants/fetch-keys"; +// services +import { UserService } from "@/services/user.service"; +import type { Route } from "./+types/page"; +const userService = new UserService(); + +export default function ProfileOverviewPage({ params }: Route.ComponentProps) { + const { workspaceSlug, userId } = params; + + const { t } = useTranslation(); + const { data: userProfile } = useSWR(USER_PROFILE_DATA(workspaceSlug, userId), () => + userService.getUserProfileData(workspaceSlug, userId) + ); + + const stateDistribution: IUserStateDistribution[] = Object.keys(GROUP_CHOICES).map((key) => { + const group = userProfile?.state_distribution.find((g) => g.state_group === key); + + if (group) return group; + else return { state_group: key as TStateGroups, state_count: 0 }; + }); + + return ( + <> + + + + +
+ + +
+ +
+ + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx new file mode 100644 index 0000000..1b3fc1e --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectArchivesHeader } from "../header"; + +export default function ProjectArchiveCyclesLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx new file mode 100644 index 0000000..d25a11b --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ArchivedCycleLayoutRoot } from "@/components/cycles/archived-cycles"; +import { ArchivedCyclesHeader } from "@/components/cycles/archived-cycles/header"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import type { Route } from "./+types/page"; + +function ProjectArchivedCyclesPage({ params }: Route.ComponentProps) { + // router + const { projectId } = params; + // store hooks + const { getProjectById } = useProject(); + // derived values + const project = getProjectById(projectId); + const pageTitle = project?.name && `${project?.name} - Archived cycles`; + + return ( + <> + +
+ + +
+ + ); +} + +export default observer(ProjectArchivedCyclesPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx new file mode 100644 index 0000000..f7b5e90 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { ArchiveIcon, CycleIcon, ModuleIcon, WorkItemsIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import { EIssuesStoreType } from "@plane/types"; +// ui +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +// hooks +import { useIssues } from "@/hooks/store/use-issues"; +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web imports +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +type TProps = { + activeTab: "issues" | "cycles" | "modules"; +}; + +const PROJECT_ARCHIVES_BREADCRUMB_LIST: { + [key: string]: { + label: string; + href: string; + icon: React.FC & { className?: string }>; + }; +} = { + issues: { + label: "Work items", + href: "/issues", + icon: WorkItemsIcon, + }, + cycles: { + label: "Cycles", + href: "/cycles", + icon: CycleIcon, + }, + modules: { + label: "Modules", + href: "/modules", + icon: ModuleIcon, + }, +}; + +export const ProjectArchivesHeader = observer(function ProjectArchivesHeader(props: TProps) { + const { activeTab } = props; + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + // store hooks + const { + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.ARCHIVED); + const { loader } = useProject(); + // hooks + const { isMobile } = usePlatformOS(); + + const issueCount = getGroupIssueCount(undefined, undefined, false); + + const activeTabBreadcrumbDetail = + PROJECT_ARCHIVES_BREADCRUMB_LIST[activeTab as keyof typeof PROJECT_ARCHIVES_BREADCRUMB_LIST]; + + return ( +
+ +
+ + + } + /> + } + /> + {activeTabBreadcrumbDetail && ( + } + /> + } + /> + )} + + {activeTab === "issues" && issueCount && issueCount > 0 ? ( + 1 ? "work items" : "work item"} in project's archived`} + position="bottom" + > + + {issueCount} + + + ) : null} +
+
+
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx new file mode 100644 index 0000000..592bf09 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +import useSWR from "swr"; +// ui +import { Banner } from "@plane/propel/banner"; +import { Button } from "@plane/propel/button"; +import { ArchiveIcon } from "@plane/propel/icons"; +import { Loader } from "@plane/ui"; +// components +import { PageHead } from "@/components/core/page-title"; +import { IssueDetailRoot } from "@/components/issues/issue-detail"; +// constants +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useProject } from "@/hooks/store/use-project"; +import type { Route } from "./+types/page"; + +function ArchivedIssueDetailsPage({ params }: Route.ComponentProps) { + // router + const { workspaceSlug, projectId, archivedIssueId } = params; + const router = useRouter(); + // states + // hooks + const { + fetchIssue, + issue: { getIssueById }, + } = useIssueDetail(); + + const { getProjectById } = useProject(); + + const { isLoading } = useSWR(`ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}`, () => + fetchIssue(workspaceSlug, projectId, archivedIssueId) + ); + + // derived values + const issue = getIssueById(archivedIssueId); + const project = issue ? getProjectById(issue?.project_id ?? "") : undefined; + const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; + + if (!issue) return <>; + + const issueLoader = !issue || isLoading; + + return ( + <> + + {issueLoader ? ( + +
+ + + + +
+
+ + + + +
+
+ ) : ( + <> + } + action={ + + } + className="border-b border-subtle" + /> +
+
+ +
+
+ + )} + + ); +} + +export default observer(ArchivedIssueDetailsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx new file mode 100644 index 0000000..e8324c2 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// ui +import { ArchiveIcon, WorkItemsIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions"; +// constants +import { ISSUE_DETAILS } from "@/constants/fetch-keys"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs/project"; +// services +import { IssueService } from "@/services/issue"; + +const issueService = new IssueService(); + +export const ProjectArchivedIssueDetailsHeader = observer(function ProjectArchivedIssueDetailsHeader() { + // router + const { workspaceSlug, projectId, archivedIssueId } = useParams(); + // store hooks + const { currentProjectDetails, loader } = useProject(); + + const { data: issueDetails } = useSWR( + workspaceSlug && projectId && archivedIssueId ? ISSUE_DETAILS(archivedIssueId.toString()) : null, + workspaceSlug && projectId && archivedIssueId + ? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString()) + : null + ); + + return ( +
+ + + + } + /> + } + /> + } + /> + } + /> + + } + /> + + + + + +
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx new file mode 100644 index 0000000..1d99a73 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectArchivedIssueDetailsHeader } from "./header"; + +export default function ProjectArchivedIssueDetailLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx new file mode 100644 index 0000000..6385a3a --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectArchivesHeader } from "../../header"; + +export default function ProjectArchiveIssuesLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx new file mode 100644 index 0000000..0a493cb --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ArchivedIssuesHeader } from "@/components/issues/archived-issues-header"; +import { ArchivedIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/archived-issue-layout-root"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import type { Route } from "./+types/page"; + +function ProjectArchivedIssuesPage({ params }: Route.ComponentProps) { + // router + const { projectId } = params; + // store hooks + const { getProjectById } = useProject(); + // derived values + const project = getProjectById(projectId); + const pageTitle = project?.name && `${project?.name} - Archived work items`; + + return ( + <> + +
+ + +
+ + ); +} + +export default observer(ProjectArchivedIssuesPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx new file mode 100644 index 0000000..0101dcf --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectArchivesHeader } from "../header"; + +export default function ProjectArchiveModulesLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx new file mode 100644 index 0000000..39a6a16 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ArchivedModuleLayoutRoot, ArchivedModulesHeader } from "@/components/modules"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import type { Route } from "./+types/page"; + +function ProjectArchivedModulesPage({ params }: Route.ComponentProps) { + // router + const { projectId } = params; + // store hooks + const { getProjectById } = useProject(); + // derived values + const project = getProjectById(projectId); + const pageTitle = project?.name && `${project?.name} - Archived modules`; + + return ( + <> + +
+ + +
+ + ); +} + +export default observer(ProjectArchivedModulesPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx new file mode 100644 index 0000000..49e6479 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { cn } from "@plane/utils"; +// assets +import emptyCycle from "@/app/assets/empty-state/cycle.svg?url"; +// components +import { EmptyState } from "@/components/common/empty-state"; +import { PageHead } from "@/components/core/page-title"; +import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details"; +import { CycleDetailsSidebar } from "@/components/cycles/analytics-sidebar"; +import { CycleLayoutRoot } from "@/components/issues/issue-layouts/roots/cycle-layout-root"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import useLocalStorage from "@/hooks/use-local-storage"; +import type { Route } from "./+types/page"; + +function CycleDetailPage({ params }: Route.ComponentProps) { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, cycleId } = params; + // store hooks + const { getCycleById, loader } = useCycle(); + const { getProjectById } = useProject(); + // const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE); + // hooks + const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false); + + useCyclesDetails({ + workspaceSlug, + projectId, + cycleId, + }); + // derived values + const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false; + const cycle = getCycleById(cycleId); + const project = getProjectById(projectId); + const pageTitle = project?.name && cycle?.name ? `${project?.name} - ${cycle?.name}` : undefined; + + /** + * Toggles the sidebar + */ + const toggleSidebar = () => setValue(!isSidebarCollapsed); + + // const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + return ( + <> + + {!cycle && !loader ? ( + router.push(`/${workspaceSlug}/projects/${projectId}/cycles`), + }} + /> + ) : ( + <> +
+
+ +
+ {!isSidebarCollapsed && ( +
+ +
+ )} +
+ + )} + + ); +} + +export default observer(CycleDetailPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx new file mode 100644 index 0000000..05f4cca --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -0,0 +1,277 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { ChartNoAxesColumn, PanelRight, SlidersHorizontal } from "lucide-react"; +// plane imports +import { + EIssueFilterType, + EUserPermissions, + EUserPermissionsLevel, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + WORK_ITEM_TRACKER_ELEMENTS, +} from "@plane/constants"; +import { usePlatformOS } from "@plane/hooks"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { IconButton } from "@plane/propel/icon-button"; +import { CycleIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; +import { Breadcrumbs, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SwitcherLabel } from "@/components/common/switcher-label"; +import { CycleQuickActions } from "@/components/cycles/quick-actions"; +import { + DisplayFiltersSelection, + FiltersDropdown, + LayoutSelection, + MobileLayoutSelection, +} from "@/components/issues/issue-layouts/filters"; +import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useCycle } from "@/hooks/store/use-cycle"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import useLocalStorage from "@/hooks/use-local-storage"; +// plane web imports +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const CycleIssuesHeader = observer(function CycleIssuesHeader() { + // refs + const parentRef = useRef(null); + // states + const [analyticsModal, setAnalyticsModal] = useState(false); + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, cycleId } = useParams(); + // i18n + const { t } = useTranslation(); + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.CYCLE); + const { currentProjectCycleIds, getCycleById } = useCycle(); + const { toggleCreateIssueModal } = useCommandPalette(); + const { currentProjectDetails, loader } = useProject(); + const { isMobile } = usePlatformOS(); + const { allowPermissions } = useUserPermissions(); + + const activeLayout = issueFilters?.displayFilters?.layout; + + const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false); + + const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false; + const toggleSidebar = () => { + setValue(!isSidebarCollapsed); + }; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + // derived values + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + const isCompletedCycle = cycleDetails?.status?.toLocaleLowerCase() === "completed"; + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + const switcherOptions = currentProjectCycleIds + ?.map((id) => { + const _cycle = id === cycleId ? cycleDetails : getCycleById(id); + if (!_cycle) return; + return { + value: _cycle.id, + query: _cycle.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + const workItemsCount = getGroupIssueCount(undefined, undefined, false); + + return ( + <> + setAnalyticsModal(false)} + cycleDetails={cycleDetails ?? undefined} + /> +
+ +
+ + + } + /> + } + /> + { + router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${value}`); + }} + title={cycleDetails?.name} + icon={ + + + + } + isLast + /> + } + isLast + /> + + {workItemsCount && workItemsCount > 0 ? ( + 1 ? "work items" : "work item" + } in this cycle`} + position="bottom" + > + + {workItemsCount} + + + ) : null} +
+
+ +
+
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> +
+
+ handleLayoutChange(layout)} + activeLayout={activeLayout} + /> +
+ + } + > + + + + {canUserCreateIssue && ( + <> + + {!isCompletedCycle && ( + + )} + + )} + + +
+
+
+ + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx new file mode 100644 index 0000000..2d92265 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { CycleIssuesHeader } from "./header"; +import { CycleIssuesMobileHeader } from "./mobile-header"; + +export default function ProjectCycleIssuesLayout() { + return ( + <> + } mobileHeader={} /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx new file mode 100644 index 0000000..56ef5ef --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { CalendarLayoutIcon, BoardLayoutIcon, ListLayoutIcon, ChevronDownIcon } from "@plane/propel/icons"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useProject } from "@/hooks/store/use-project"; + +const SUPPORTED_LAYOUTS = [ + { key: "list", titleTranslationKey: "issue.layouts.list", icon: ListLayoutIcon }, + { key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: BoardLayoutIcon }, + { key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: CalendarLayoutIcon }, +]; + +export const CycleIssuesMobileHeader = observer(function CycleIssuesMobileHeader() { + // router + const { workspaceSlug, projectId, cycleId } = useParams(); + // states + const [analyticsModal, setAnalyticsModal] = useState(false); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentProjectDetails } = useProject(); + const { getCycleById } = useCycle(); + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.CYCLE); + // derived values + const activeLayout = issueFilters?.displayFilters?.layout; + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId || !cycleId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout }, + cycleId.toString() + ); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId || !cycleId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + cycleId.toString() + ); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId || !cycleId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_PROPERTIES, + property, + cycleId.toString() + ); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + cycleDetails={cycleDetails ?? undefined} + /> +
+ {t("common.layout")} + } + customButtonClassName="flex flex-grow justify-center text-secondary text-13" + closeOnSelect + > + {SUPPORTED_LAYOUTS.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{t(layout.titleTranslationKey)}
+
+ ))} +
+
+ + {t("common.display")} + + + } + > + + +
+ + setAnalyticsModal(true)} + className="flex flex-grow justify-center border-l border-subtle text-13 text-secondary" + > + {t("common.analytics")} + +
+ + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx new file mode 100644 index 0000000..62cac65 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// ui +import { EUserPermissions, EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { CycleIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { CyclesViewHeader } from "@/components/cycles/cycles-view-header"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const CyclesListHeader = observer(function CyclesListHeader() { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + + // store hooks + const { toggleCreateCycleModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + const { currentProjectDetails, loader } = useProject(); + const { t } = useTranslation(); + + const canUserCreateCycle = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + return ( +
+ + + + } + isLast + /> + } + isLast + /> + + + {canUserCreateCycle && currentProjectDetails ? ( + + + + + ) : ( + <> + )} +
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx new file mode 100644 index 0000000..c1b0033 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { CyclesListHeader } from "./header"; +import { CyclesListMobileHeader } from "./mobile-header"; + +export default function ProjectCyclesListLayout() { + return ( + <> + } mobileHeader={} /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx new file mode 100644 index 0000000..b5108be --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type React from "react"; +import { observer } from "mobx-react"; +// ui +import type { ISvgIcons } from "@plane/propel/icons"; +import { TimelineLayoutIcon, GridLayoutIcon, ListLayoutIcon } from "@plane/propel/icons"; +// plane package imports +import type { TCycleLayoutOptions } from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +// hooks +import { useCycleFilter } from "@/hooks/store/use-cycle-filter"; +import { useProject } from "@/hooks/store/use-project"; + +const CYCLE_VIEW_LAYOUTS: { + key: TCycleLayoutOptions; + icon: React.FC; + title: string; +}[] = [ + { + key: "list", + icon: ListLayoutIcon, + title: "List layout", + }, + { + key: "board", + icon: GridLayoutIcon, + title: "Gallery layout", + }, + { + key: "gantt", + icon: TimelineLayoutIcon, + title: "Timeline layout", + }, +]; + +export const CyclesListMobileHeader = observer(function CyclesListMobileHeader() { + const { currentProjectDetails } = useProject(); + // hooks + const { updateDisplayFilters } = useCycleFilter(); + return ( +
+ + + Layout + + } + customButtonClassName="flex flex-grow justify-center items-center text-secondary text-13" + closeOnSelect + > + {CYCLE_VIEW_LAYOUTS.map((layout) => { + if (layout.key == "gantt") return; + return ( + { + updateDisplayFilters(currentProjectDetails!.id, { + layout: layout.key, + }); + }} + className="flex items-center gap-2" + > + +
{layout.title}
+
+ ); + })} +
+
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx new file mode 100644 index 0000000..d92cd73 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { useTheme } from "next-themes"; +import { EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EmptyStateDetailed } from "@plane/propel/empty-state"; +import type { TCycleFilters } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; +// components +import { Header, EHeaderVariant } from "@plane/ui"; +import { calculateTotalFilters } from "@plane/utils"; +// assets +import darkEmptyState from "@/app/assets/empty-state/disabled-feature/cycles-dark.webp?url"; +import lightEmptyState from "@/app/assets/empty-state/disabled-feature/cycles-light.webp?url"; +// components +import { PageHead } from "@/components/core/page-title"; +import { CycleAppliedFiltersList } from "@/components/cycles/applied-filters"; +import { CyclesView } from "@/components/cycles/cycles-view"; +import { CycleCreateUpdateModal } from "@/components/cycles/modal"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useCycleFilter } from "@/hooks/store/use-cycle-filter"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import type { Route } from "./+types/page"; + +function ProjectCyclesPage({ params }: Route.ComponentProps) { + // states + const [createModal, setCreateModal] = useState(false); + // store hooks + const { currentProjectCycleIds, loader } = useCycle(); + const { getProjectById, currentProjectDetails } = useProject(); + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = params; + // theme hook + const { resolvedTheme } = useTheme(); + // plane hooks + const { t } = useTranslation(); + // cycle filters hook + const { clearAllFilters, currentProjectFilters, updateFilters } = useCycleFilter(); + const { allowPermissions } = useUserPermissions(); + // derived values + const resolvedEmptyState = resolvedTheme === "light" ? lightEmptyState : darkEmptyState; + const totalCycles = currentProjectCycleIds?.length ?? 0; + const project = getProjectById(projectId); + const pageTitle = project?.name ? `${project?.name} - ${t("common.cycles", { count: 2 })}` : undefined; + const hasAdminLevelPermission = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const hasMemberLevelPermission = allowPermissions( + [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => { + let newValues = currentProjectFilters?.[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId, { [key]: newValues }); + }; + + // No access to cycle + if (currentProjectDetails?.cycle_view === false) + return ( +
+ { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !hasAdminLevelPermission, + }} + /> +
+ ); + + if (loader) return ; + + return ( + <> + +
+ setCreateModal(false)} + /> + {totalCycles === 0 ? ( +
+ setCreateModal(true), + variant: "primary", + disabled: !hasMemberLevelPermission, + "data-ph-element": CYCLE_TRACKER_ELEMENTS.EMPTY_STATE_ADD_BUTTON, + }, + ]} + /> +
+ ) : ( + <> + {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( +
+ clearAllFilters(projectId)} + handleRemoveFilter={handleRemoveFilter} + /> +
+ )} + + + + )} +
+ + ); +} + +export default observer(ProjectCyclesPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx new file mode 100644 index 0000000..267226e --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectInboxHeader } from "@/plane-web/components/projects/settings/intake/header"; + +export default function ProjectInboxIssuesLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx new file mode 100644 index 0000000..c6be164 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EUserProjectRoles, EInboxIssueCurrentTab } from "@plane/types"; +// assets +import darkIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-dark.webp?url"; +import lightIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-light.webp?url"; +// components +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { InboxIssueRoot } from "@/components/inbox"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import type { Route } from "./+types/page"; + +function ProjectInboxPage({ params }: Route.ComponentProps) { + /// router + const router = useAppRouter(); + const { workspaceSlug, projectId } = params; + const searchParams = useSearchParams(); + const navigationTab = searchParams.get("currentTab"); + const inboxIssueId = searchParams.get("inboxIssueId"); + // theme hook + const { resolvedTheme } = useTheme(); + // plane hooks + const { t } = useTranslation(); + // hooks + const { currentProjectDetails } = useProject(); + const { allowPermissions } = useUserPermissions(); + // derived values + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = resolvedTheme === "light" ? lightIntakeAsset : darkIntakeAsset; + + // No access to inbox + if (currentProjectDetails?.inbox_view === false) + return ( +
+ { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
+ ); + + // derived values + const pageTitle = currentProjectDetails?.name + ? t("inbox_issue.page_label", { + workspace: currentProjectDetails?.name, + }) + : t("inbox_issue.page_label", { + workspace: "NODE.DC", + }); + + const currentNavigationTab = navigationTab + ? navigationTab === "open" + ? EInboxIssueCurrentTab.OPEN + : EInboxIssueCurrentTab.CLOSED + : undefined; + + return ( +
+ +
+ +
+
+ ); +} + +export default observer(ProjectInboxPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx new file mode 100644 index 0000000..1fab5aa --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useTheme } from "next-themes"; +import { redirect } from "react-router"; +import { useTranslation } from "@plane/i18n"; +// assets +import emptyIssueDark from "@/app/assets/empty-state/search/issues-dark.webp?url"; +import emptyIssueLight from "@/app/assets/empty-state/search/issues-light.webp?url"; +// components +import { EmptyState } from "@/components/common/empty-state"; +import { LogoSpinner } from "@/components/common/logo-spinner"; +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; +// services +import { IssueService } from "@/services/issue/issue.service"; +// types +import type { Route } from "./+types/page"; + +const issueService = new IssueService(); + +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + const { workspaceSlug, projectId, issueId } = params; + + try { + const data = await issueService.getIssueMetaFromURL(workspaceSlug, projectId, issueId); + + if (data) { + throw redirect(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`); + } + + return { error: true, workspaceSlug }; + } catch (error) { + // If it's a redirect, rethrow it + if (error instanceof Response) { + throw error; + } + // Otherwise return error state + return { error: true, workspaceSlug }; + } +} + +export default function IssueDetailsPage({ loaderData }: Route.ComponentProps) { + const router = useAppRouter(); + const { t } = useTranslation(); + const { resolvedTheme } = useTheme(); + + if (loaderData.error) { + return ( +
+ router.push(`/${loaderData.workspaceSlug}/workspace-views/all-issues/`), + }} + /> +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx new file mode 100644 index 0000000..eb82758 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx @@ -0,0 +1,11 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { IssuesHeader } from "@/plane-web/components/issues/header"; + +export function ProjectIssuesHeader() { + return ; +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx new file mode 100644 index 0000000..c8ac595 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import { Outlet } from "react-router"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectIssuesHeader } from "./header"; +import { ProjectIssuesMobileHeader } from "./mobile-header"; + +export default function ProjectIssuesLayout() { + return ( + <> + } mobileHeader={} /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx new file mode 100644 index 0000000..dfe400d --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { ChevronDownIcon } from "@plane/propel/icons"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { + DisplayFiltersSelection, + FiltersDropdown, + MobileLayoutSelection, +} from "@/components/issues/issue-layouts/filters"; +// hooks +import { useIssues } from "@/hooks/store/use-issues"; +import { useProject } from "@/hooks/store/use-project"; + +export const ProjectIssuesMobileHeader = observer(function ProjectIssuesMobileHeader() { + // i18n + const { t } = useTranslation(); + const [analyticsModal, setAnalyticsModal] = useState(false); + const { workspaceSlug, projectId } = useParams(); + const { currentProjectDetails } = useProject(); + + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT); + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); + }, + [workspaceSlug, projectId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + projectDetails={currentProjectDetails ?? undefined} + /> +
+ +
+ + {t("common.display")} + + + } + > + + +
+ + +
+ + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx new file mode 100644 index 0000000..15ebb8b --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// i18n +import { useTranslation } from "@plane/i18n"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ProjectLayoutRoot } from "@/components/issues/issue-layouts/roots/project-layout-root"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import type { Route } from "./+types/page"; + +function ProjectIssuesPage({ params }: Route.ComponentProps) { + const { projectId } = params; + // i18n + const { t } = useTranslation(); + // store + const { getProjectById } = useProject(); + + // derived values + const project = getProjectById(projectId); + const pageTitle = project?.name ? `${project?.name} - ${t("issue.label", { count: 2 })}` : undefined; // Count is for pluralization + + return ( + <> + +
+ +
+ + ); +} + +export default observer(ProjectIssuesPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx new file mode 100644 index 0000000..e43b6eb --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { Outlet } from "react-router"; +// plane imports +import { Header, Row } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { TabNavigationRoot } from "@/components/navigation/tab-navigation-root"; +import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences"; +// layouts +import { ProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper"; +// local imports +import type { Route } from "./+types/layout"; + +function ProjectLayout({ params }: Route.ComponentProps) { + // router + const { workspaceSlug, projectId } = params; + // store hooks + const { sidebarCollapsed } = useAppTheme(); + // preferences + const { preferences: projectPreferences } = useProjectNavigationPreferences(); + + return ( + <> + {projectPreferences.navigationMode === "TABBED" && ( +
+ +
+
+ {sidebarCollapsed && ( +
+ +
+ )} +
+ + + +
+
+
+
+
+ )} + + + + + ); +} + +export default observer(ProjectLayout); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx new file mode 100644 index 0000000..1066ed4 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane imports +import { cn } from "@plane/utils"; +// assets +import emptyModule from "@/app/assets/empty-state/module.svg?url"; +// components +import { EmptyState } from "@/components/common/empty-state"; +import { PageHead } from "@/components/core/page-title"; +import { ModuleLayoutRoot } from "@/components/issues/issue-layouts/roots/module-layout-root"; +import { ModuleAnalyticsSidebar } from "@/components/modules"; +// hooks +import { useModule } from "@/hooks/store/use-module"; +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import useLocalStorage from "@/hooks/use-local-storage"; +import type { Route } from "./+types/page"; + +function ModuleIssuesPage({ params }: Route.ComponentProps) { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, moduleId } = params; + // store hooks + const { fetchModuleDetails, getModuleById } = useModule(); + const { getProjectById } = useProject(); + // const { issuesFilter } = useIssues(EIssuesStoreType.MODULE); + // local storage + const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); + const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; + // fetching module details + const { error } = useSWR(`CURRENT_MODULE_DETAILS_${moduleId}`, () => + fetchModuleDetails(workspaceSlug, projectId, moduleId) + ); + // derived values + const projectModule = getModuleById(moduleId); + const project = getProjectById(projectId); + const pageTitle = project?.name && projectModule?.name ? `${project?.name} - ${projectModule?.name}` : undefined; + + const toggleSidebar = () => { + setValue(`${!isSidebarCollapsed}`); + }; + + // const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + return ( + <> + + {error ? ( + router.push(`/${workspaceSlug}/projects/${projectId}/modules`), + }} + /> + ) : ( +
+
+ +
+ {!isSidebarCollapsed && ( +
+ +
+ )} +
+ )} + + ); +} + +export default observer(ModuleIssuesPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx new file mode 100644 index 0000000..5524626 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -0,0 +1,274 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { ChartNoAxesColumn, PanelRight, SlidersHorizontal } from "lucide-react"; +// plane imports +import { + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + EUserPermissions, + EUserPermissionsLevel, + WORK_ITEM_TRACKER_ELEMENTS, +} from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { ModuleIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; +import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SwitcherLabel } from "@/components/common/switcher-label"; +import { + DisplayFiltersSelection, + FiltersDropdown, + LayoutSelection, + MobileLayoutSelection, +} from "@/components/issues/issue-layouts/filters"; +import { ModuleQuickActions } from "@/components/modules"; +import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useModule } from "@/hooks/store/use-module"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useIssuesActions } from "@/hooks/use-issues-actions"; +import useLocalStorage from "@/hooks/use-local-storage"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web imports +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +import { IconButton } from "@plane/propel/icon-button"; + +export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() { + // refs + const parentRef = useRef(null); + // states + const [analyticsModal, setAnalyticsModal] = useState(false); + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, moduleId: routerModuleId } = useParams(); + const moduleId = routerModuleId ? routerModuleId.toString() : undefined; + // hooks + const { isMobile } = usePlatformOS(); + // store hooks + const { + issuesFilter: { issueFilters }, + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.MODULE); + const { updateFilters } = useIssuesActions(EIssuesStoreType.MODULE); + const { projectModuleIds, getModuleById } = useModule(); + const { toggleCreateIssueModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + const { currentProjectDetails, loader } = useProject(); + // local storage + const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); + // derived values + const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; + const activeLayout = issueFilters?.displayFilters?.layout; + const moduleDetails = moduleId ? getModuleById(moduleId) : undefined; + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + const workItemsCount = getGroupIssueCount(undefined, undefined, false); + + const toggleSidebar = () => { + setValue(`${!isSidebarCollapsed}`); + }; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!projectId) return; + updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); + }, + [projectId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!projectId) return; + updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); + }, + [projectId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!projectId) return; + updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property); + }, + [projectId, updateFilters] + ); + + const switcherOptions = projectModuleIds + ?.map((id) => { + const _module = id === moduleId ? moduleDetails : getModuleById(id); + if (!_module) return; + return { + value: _module.id, + query: _module.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + return ( + <> + setAnalyticsModal(false)} + moduleDetails={moduleDetails ?? undefined} + projectDetails={currentProjectDetails} + /> +
+ +
+ + + } + isLast + /> + } + isLast + /> + { + router.push(`/${workspaceSlug}/projects/${projectId}/modules/${value}`); + }} + title={moduleDetails?.name} + icon={} + isLast + /> + } + /> + + {workItemsCount && workItemsCount > 0 ? ( + 1 ? "work items" : "work item" + } in this module`} + position="bottom" + > + + {workItemsCount} + + + ) : null} +
+
+ +
+
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> +
+
+ handleLayoutChange(layout)} + activeLayout={activeLayout} + /> +
+ {moduleId && } + } + > + + +
+ + {canUserCreateIssue ? ( + <> + + + + ) : ( + <> + )} + + {moduleId && ( + + )} +
+
+ + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx new file mode 100644 index 0000000..f11b906 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ModuleIssuesHeader } from "./header"; +import { ModuleIssuesMobileHeader } from "./mobile-header"; + +export default function ProjectModuleIssuesLayout() { + return ( + <> + } mobileHeader={} /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx new file mode 100644 index 0000000..317a7b0 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { CalendarLayoutIcon, BoardLayoutIcon, ListLayoutIcon, ChevronDownIcon } from "@plane/propel/icons"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon"; +// hooks +import { useIssues } from "@/hooks/store/use-issues"; +import { useModule } from "@/hooks/store/use-module"; +import { useProject } from "@/hooks/store/use-project"; + +const SUPPORTED_LAYOUTS = [ + { key: "list", i18n_title: "issue.layouts.list", icon: ListLayoutIcon }, + { key: "kanban", i18n_title: "issue.layouts.kanban", icon: BoardLayoutIcon }, + { key: "calendar", i18n_title: "issue.layouts.calendar", icon: CalendarLayoutIcon }, +]; + +export const ModuleIssuesMobileHeader = observer(function ModuleIssuesMobileHeader() { + // router + const { workspaceSlug, projectId, moduleId } = useParams(); + // states + const [analyticsModal, setAnalyticsModal] = useState(false); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentProjectDetails } = useProject(); + const { getModuleById } = useModule(); + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.MODULE); + // derived values + const activeLayout = issueFilters?.displayFilters?.layout; + const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + return ( +
+ setAnalyticsModal(false)} + moduleDetails={moduleDetails ?? undefined} + projectDetails={currentProjectDetails} + /> +
+ Layout} + customButtonClassName="flex flex-grow justify-center text-secondary text-13" + closeOnSelect + > + {SUPPORTED_LAYOUTS.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{t(layout.i18n_title)}
+
+ ))} +
+
+ + Display + + + } + > + + +
+ + +
+
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx new file mode 100644 index 0000000..6768882 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel, MODULE_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button } from "@plane/propel/button"; +import { ModuleIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { ModuleViewHeader } from "@/components/modules"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const ModulesListHeader = observer(function ModulesListHeader() { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + // store hooks + const { toggleCreateModuleModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + + const { loader } = useProject(); + + const { t } = useTranslation(); + + // auth + const canUserCreateModule = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + return ( +
+ +
+ + + } + isLast + /> + } + isLast + /> + +
+
+ + + {canUserCreateModule ? ( + + ) : ( + <> + )} + +
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx new file mode 100644 index 0000000..46eb9f1 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ModulesListHeader } from "./header"; +import { ModulesListMobileHeader } from "./mobile-header"; + +export default function ProjectModulesListLayout() { + return ( + <> + } mobileHeader={} /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx new file mode 100644 index 0000000..a571f7c --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { MODULE_VIEW_LAYOUTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { ChevronDownIcon } from "@plane/propel/icons"; +import { CustomMenu, Row } from "@plane/ui"; +import { ModuleLayoutIcon } from "@/components/modules"; +import { useModuleFilter } from "@/hooks/store/use-module-filter"; +import { useProject } from "@/hooks/store/use-project"; + +export const ModulesListMobileHeader = observer(function ModulesListMobileHeader() { + const { currentProjectDetails } = useProject(); + const { updateDisplayFilters } = useModuleFilter(); + const { t } = useTranslation(); + + return ( +
+ + Layout + + } + customButtonClassName="flex flex-grow justify-center items-center text-secondary text-13" + closeOnSelect + > + {MODULE_VIEW_LAYOUTS.map((layout) => { + if (layout.key == "gantt") return; + return ( + { + updateDisplayFilters(currentProjectDetails!.id.toString(), { layout: layout.key }); + }} + className="flex items-center gap-2" + > + +
{t(layout.i18n_title)}
+
+ ); + })} +
+
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx new file mode 100644 index 0000000..12e0be1 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { TModuleFilters } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; +import { calculateTotalFilters } from "@plane/utils"; +// assets +import darkModulesAsset from "@/app/assets/empty-state/disabled-feature/modules-dark.webp?url"; +import lightModulesAsset from "@/app/assets/empty-state/disabled-feature/modules-light.webp?url"; +// components +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; +// hooks +import { useModuleFilter } from "@/hooks/store/use-module-filter"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import type { Route } from "./+types/page"; + +function ProjectModulesPage({ params }: Route.ComponentProps) { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = params; + // theme hook + const { resolvedTheme } = useTheme(); + // plane hooks + const { t } = useTranslation(); + // store + const { getProjectById, currentProjectDetails } = useProject(); + const { + currentProjectFilters = {}, + currentProjectDisplayFilters, + clearAllFilters, + updateFilters, + updateDisplayFilters, + } = useModuleFilter(); + const { allowPermissions } = useUserPermissions(); + // derived values + const project = getProjectById(projectId); + const pageTitle = project?.name ? `${project?.name} - Modules` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = resolvedTheme === "light" ? lightModulesAsset : darkModulesAsset; + + const handleRemoveFilter = useCallback( + (key: keyof TModuleFilters, value: string | null) => { + let newValues = currentProjectFilters[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId, { [key]: newValues }); + }, + [currentProjectFilters, projectId, updateFilters] + ); + + // No access to + if (currentProjectDetails?.module_view === false) + return ( +
+ { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
+ ); + + return ( + <> + +
+ {(calculateTotalFilters(currentProjectFilters) !== 0 || currentProjectDisplayFilters?.favorites) && ( + clearAllFilters(projectId)} + handleRemoveFilter={handleRemoveFilter} + handleDisplayFiltersUpdate={(val) => updateDisplayFilters(projectId, val)} + alwaysAllowEditing + /> + )} + +
+ + ); +} + +export default observer(ProjectModulesPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx new file mode 100644 index 0000000..ec332bb --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useEffect, useMemo } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import useSWR from "swr"; +// plane types +import { getButtonStyling } from "@plane/propel/button"; +import type { TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types"; +import { EFileAssetType } from "@plane/types"; +// plane ui +// plane utils +import { cn } from "@plane/utils"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PageHead } from "@/components/core/page-title"; +import { IssuePeekOverview } from "@/components/issues/peek-overview"; +import type { TPageRootConfig, TPageRootHandlers } from "@/components/pages/editor/page-root"; +import { PageRoot } from "@/components/pages/editor/page-root"; +// hooks +import { useEditorConfig } from "@/hooks/editor"; +import { useEditorAsset } from "@/hooks/store/use-editor-asset"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web hooks +import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; +// plane web services +import { WorkspaceService } from "@/services/workspace.service"; +// services +import { ProjectPageService, ProjectPageVersionService } from "@/services/page"; +import type { Route } from "./+types/page"; +const workspaceService = new WorkspaceService(); +const projectPageService = new ProjectPageService(); +const projectPageVersionService = new ProjectPageVersionService(); + +const storeType = EPageStoreType.PROJECT; + +function PageDetailsPage({ params }: Route.ComponentProps) { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, pageId } = params; + // store hooks + const { createPage, fetchPageDetails } = usePageStore(storeType); + const page = usePage({ + pageId, + storeType, + }); + const { getWorkspaceBySlug } = useWorkspace(); + const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset(); + // derived values + const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug)?.id ?? "") : ""; + const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {}; + // entity search handler + const fetchEntityCallback = useCallback( + async (payload: TSearchEntityRequestPayload) => + await workspaceService.searchEntity(workspaceSlug, { + ...payload, + project_id: projectId, + }), + [projectId, workspaceSlug] + ); + // editor config + const { getEditorFileHandlers } = useEditorConfig(); + // fetch page details + const { error: pageDetailsError } = useSWR( + `PAGE_DETAILS_${pageId}`, + () => fetchPageDetails(workspaceSlug, projectId, pageId), + { + revalidateIfStale: true, + revalidateOnFocus: true, + revalidateOnReconnect: true, + } + ); + // page root handlers + const pageRootHandlers: TPageRootHandlers = useMemo( + () => ({ + create: createPage, + fetchAllVersions: async (pageId) => + await projectPageVersionService.fetchAllVersions(workspaceSlug, projectId, pageId), + fetchDescriptionBinary: async () => { + if (!id) return; + return await projectPageService.fetchDescriptionBinary(workspaceSlug, projectId, id); + }, + fetchEntity: fetchEntityCallback, + fetchVersionDetails: async (pageId, versionId) => + await projectPageVersionService.fetchVersionById(workspaceSlug, projectId, pageId, versionId), + restoreVersion: async (pageId, versionId) => + await projectPageVersionService.restoreVersion(workspaceSlug, projectId, pageId, versionId), + getRedirectionLink: (pageId) => { + if (pageId) { + return `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`; + } else { + return `/${workspaceSlug}/projects/${projectId}/pages`; + } + }, + updateDescription: updateDescription ?? (async () => {}), + }), + [createPage, fetchEntityCallback, id, updateDescription, workspaceSlug, projectId] + ); + // page root config + const pageRootConfig: TPageRootConfig = useMemo( + () => ({ + fileHandler: getEditorFileHandlers({ + projectId, + uploadFile: async (blockId, file) => { + const { asset_id } = await uploadEditorAsset({ + blockId, + data: { + entity_identifier: id ?? "", + entity_type: EFileAssetType.PAGE_DESCRIPTION, + }, + file, + projectId, + workspaceSlug, + }); + return asset_id; + }, + duplicateFile: async (assetId: string) => { + const { asset_id } = await duplicateEditorAsset({ + assetId, + entityId: id, + entityType: EFileAssetType.PAGE_DESCRIPTION, + projectId, + workspaceSlug, + }); + return asset_id; + }, + workspaceId, + workspaceSlug, + }), + }), + [getEditorFileHandlers, projectId, workspaceId, workspaceSlug, uploadEditorAsset, id, duplicateEditorAsset] + ); + + const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo( + () => ({ + documentType: "project_page", + projectId, + workspaceSlug, + }), + [projectId, workspaceSlug] + ); + + useEffect(() => { + if (page?.deleted_at && page?.id) { + router.push(pageRootHandlers.getRedirectionLink()); + } + }, [page?.deleted_at, page?.id, router, pageRootHandlers]); + + if ((!page || !id) && !pageDetailsError) + return ( +
+ +
+ ); + + if (pageDetailsError || !canCurrentUserAccessPage) + return ( +
+

Page not found

+

+ The page you are trying to access doesn{"'"}t exist or you don{"'"}t have permission to view it. +

+ + View other Pages + +
+ ); + + if (!page) return null; + + return ( + <> + +
+
+ + +
+
+ + ); +} + +export default observer(PageDetailsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx new file mode 100644 index 0000000..271d0a1 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { PageIcon } from "@plane/propel/icons"; +import type { ICustomSearchSelectOption } from "@plane/types"; +import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +import { getPageName } from "@plane/utils"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { PageAccessIcon } from "@/components/common/page-access-icon"; +import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label"; +import { PageHeaderActions } from "@/components/pages/header/actions"; +import { PageSyncingBadge } from "@/components/pages/header/syncing-badge"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages"; +import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; + +export interface IPagesHeaderProps { + showButton?: boolean; +} + +const storeType = EPageStoreType.PROJECT; + +export const PageDetailsHeader = observer(function PageDetailsHeader() { + // router + const router = useAppRouter(); + const { workspaceSlug, pageId, projectId } = useParams(); + // store hooks + const { loader } = useProject(); + const { getPageById, getCurrentProjectPageIds } = usePageStore(storeType); + const page = usePage({ + pageId: pageId?.toString() ?? "", + storeType, + }); + // derived values + const projectPageIds = getCurrentProjectPageIds(projectId?.toString()); + + const switcherOptions = projectPageIds + .map((id) => { + const _page = id === pageId ? page : getPageById(id); + if (!_page) return; + return { + value: _page.id, + query: _page.name, + content: ( +
+ + +
+ ), + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + if (!page) return null; + + return ( +
+ +
+ + + } + /> + } + /> + + { + router.push(`/${workspaceSlug}/projects/${projectId}/pages/${value}`); + }} + title={getPageName(page?.name)} + icon={ + + + + } + isLast + /> + } + /> + +
+
+ + + + + +
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx new file mode 100644 index 0000000..0ba3728 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// component +import { Outlet } from "react-router"; +import useSWR from "swr"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// plane web hooks +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; +// local components +import type { Route } from "./+types/layout"; +import { PageDetailsHeader } from "./header"; + +export default function ProjectPageDetailsLayout({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + const { fetchPagesList } = usePageStore(EPageStoreType.PROJECT); + // fetching pages list + useSWR(`PROJECT_PAGES_${projectId}`, () => fetchPagesList(workspaceSlug, projectId)); + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx new file mode 100644 index 0000000..e76f55b --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +// constants +import { EPageAccess } from "@plane/constants"; +// plane types +import { Button } from "@plane/propel/button"; +import { PageIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TPage } from "@plane/types"; +// plane ui +import { Breadcrumbs, Header } from "@plane/ui"; +// helpers +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +// plane web imports +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; + +export const PagesListHeader = observer(function PagesListHeader() { + // states + const [isCreatingPage, setIsCreatingPage] = useState(false); + // router + const router = useRouter(); + const { workspaceSlug, projectId } = useParams(); + const searchParams = useSearchParams(); + const pageType = searchParams.get("type"); + // store hooks + const { currentProjectDetails, loader } = useProject(); + const { canCurrentUserCreatePage, createPage } = usePageStore(EPageStoreType.PROJECT); + // handle page create + const handleCreatePage = async () => { + setIsCreatingPage(true); + + const payload: Partial = { + access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC, + }; + + await createPage(payload) + .then((res) => { + const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`; + router.push(pageId); + }) + .catch((err) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.data?.error || "Page could not be created. Please try again.", + }); + }) + .finally(() => setIsCreatingPage(false)); + }; + + return ( +
+ + + + } + isLast + /> + } + isLast + /> + + + {canCurrentUserCreatePage && ( + + + + )} +
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx new file mode 100644 index 0000000..b803854 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import { Outlet } from "react-router"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local components +import { PagesListHeader } from "./header"; + +export default function ProjectPagesListLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx new file mode 100644 index 0000000..e5e1de9 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { TPageNavigationTabs } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; +// assets +import darkPagesAsset from "@/app/assets/empty-state/disabled-feature/pages-dark.webp?url"; +import lightPagesAsset from "@/app/assets/empty-state/disabled-feature/pages-light.webp?url"; +// components +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { PagesListRoot } from "@/components/pages/list/root"; +import { PagesListView } from "@/components/pages/pages-list-view"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web hooks +import { EPageStoreType } from "@/plane-web/hooks/store"; +import type { Route } from "./+types/page"; + +const getPageType = (pageType?: string | null): TPageNavigationTabs => { + if (pageType === "private") return "private"; + if (pageType === "archived") return "archived"; + return "public"; +}; + +function ProjectPagesPage({ params }: Route.ComponentProps) { + // router + const router = useAppRouter(); + const searchParams = useSearchParams(); + const type = searchParams.get("type"); + const { workspaceSlug, projectId } = params; + // theme hook + const { resolvedTheme } = useTheme(); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { getProjectById, currentProjectDetails } = useProject(); + const { allowPermissions } = useUserPermissions(); + // derived values + const project = getProjectById(projectId); + const pageTitle = project?.name ? `${project?.name} - Pages` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = resolvedTheme === "light" ? lightPagesAsset : darkPagesAsset; + const pageType = getPageType(type); + + // No access to cycle + if (currentProjectDetails?.page_view === false) + return ( +
+ { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
+ ); + return ( + <> + + + + + + ); +} + +export default observer(ProjectPagesPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx new file mode 100644 index 0000000..3393257 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -0,0 +1,223 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useRef } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; + +// plane imports +import { + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + EUserPermissions, + EUserPermissionsLevel, + WORK_ITEM_TRACKER_ELEMENTS, +} from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { LockIcon, ViewsIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EViewAccess, EIssueLayoutTypes } from "@plane/types"; +import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label"; +import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters"; +import { ViewQuickActions } from "@/components/views/quick-actions"; +import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useProject } from "@/hooks/store/use-project"; +import { useProjectView } from "@/hooks/store/use-project-view"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web imports +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader() { + // refs + const parentRef = useRef(null); + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, viewId: routerViewId } = useParams(); + const viewId = routerViewId ? routerViewId.toString() : undefined; + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT_VIEW); + const { toggleCreateIssueModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + + const { currentProjectDetails, loader } = useProject(); + const { projectViewIds, getViewById } = useProjectView(); + + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId || !viewId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout }, + viewId.toString() + ); + }, + [workspaceSlug, projectId, viewId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId || !viewId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + viewId.toString() + ); + }, + [workspaceSlug, projectId, viewId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId || !viewId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_PROPERTIES, + property, + viewId.toString() + ); + }, + [workspaceSlug, projectId, viewId, updateFilters] + ); + + const viewDetails = viewId ? getViewById(viewId.toString()) : null; + + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + if (!viewDetails) return; + + const switcherOptions = projectViewIds + ?.map((id) => { + const _view = id === viewId ? viewDetails : getViewById(id); + if (!_view) return; + return { + value: _view.id, + query: _view.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + return ( +
+ + + + } + /> + } + /> + { + router.push(`/${workspaceSlug}/projects/${projectId}/views/${value}`); + }} + title={viewDetails?.name} + icon={ + + + + } + isLast + /> + } + /> + + + {viewDetails?.access === EViewAccess.PRIVATE ? ( +
+ + + +
+ ) : ( + <> + )} +
+ + <> + {!viewDetails.is_locked && ( + handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> + )} + {viewId && } + {!viewDetails.is_locked && ( + + + + )} + + {canUserCreateIssue && ( + + )} +
+ +
+
+
+ ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx new file mode 100644 index 0000000..c4c03eb --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import useSWR from "swr"; +// assets +import emptyView from "@/app/assets/empty-state/view.svg?url"; +// components +import { EmptyState } from "@/components/common/empty-state"; +import { PageHead } from "@/components/core/page-title"; +import { ProjectViewLayoutRoot } from "@/components/issues/issue-layouts/roots/project-view-layout-root"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useProjectView } from "@/hooks/store/use-project-view"; +import { useAppRouter } from "@/hooks/use-app-router"; +import type { Route } from "./+types/page"; + +function ProjectViewIssuesPage({ params }: Route.ComponentProps) { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, viewId } = params; + // store hooks + const { fetchViewDetails, getViewById } = useProjectView(); + const { getProjectById } = useProject(); + // derived values + const projectView = getViewById(viewId); + const project = getProjectById(projectId); + const pageTitle = project?.name && projectView?.name ? `${project?.name} - ${projectView?.name}` : undefined; + + const { error } = useSWR(`VIEW_DETAILS_${viewId}`, () => fetchViewDetails(workspaceSlug, projectId, viewId)); + + if (error) { + return ( + router.push(`/${workspaceSlug}/projects/${projectId}/views`), + }} + /> + ); + } + + return ( + <> + + + + ); +} + +export default observer(ProjectViewIssuesPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx new file mode 100644 index 0000000..b33381b --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local components +import { ProjectViewIssuesHeader } from "./[viewId]/header"; + +export default function ProjectViewIssuesLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx new file mode 100644 index 0000000..c051dd9 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// ui +import { PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { ViewsIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { ViewListHeader } from "@/components/views/view-list-header"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useProject } from "@/hooks/store/use-project"; +// plane web imports +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const ProjectViewsHeader = observer(function ProjectViewsHeader() { + const { workspaceSlug, projectId } = useParams(); + const { t } = useTranslation(); + // store hooks + const { toggleCreateViewModal } = useCommandPalette(); + const { loader } = useProject(); + + return ( + <> +
+ + + + } + isLast + /> + } + isLast + /> + + + + +
+ +
+
+
+ + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx new file mode 100644 index 0000000..0b6dda9 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local components +import { ProjectViewsHeader } from "./header"; +import { ViewMobileHeader } from "./mobile-header"; + +export default function ProjectViewsListLayout() { + return ( + <> + } mobileHeader={} /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx new file mode 100644 index 0000000..73e5f72 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// icons +import { ListFilter } from "lucide-react"; +import { ChevronDownIcon } from "@plane/propel/icons"; +// components +import { Row } from "@plane/ui"; +import { FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { ViewFiltersSelection } from "@/components/views/filters/filter-selection"; +import { ViewOrderByDropdown } from "@/components/views/filters/order-by"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +import { useProjectView } from "@/hooks/store/use-project-view"; + +export const ViewMobileHeader = observer(function ViewMobileHeader() { + // store hooks + const { filters, updateFilters } = useProjectView(); + const { + project: { projectMemberIds }, + } = useMember(); + + return ( + <> +
+ + { + if (val.key) updateFilters("sortKey", val.key); + if (val.order) updateFilters("sortBy", val.order); + }} + isMobile + /> + +
+ } + title="Filters" + placement="bottom-end" + isFiltersApplied={false} + menuButton={ + + Filters + + + } + > + + +
+
+ + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx new file mode 100644 index 0000000..af05eab --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { EViewAccess, TViewFilterProps } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; +import { Header, EHeaderVariant } from "@plane/ui"; +import { calculateTotalFilters } from "@plane/utils"; +// assets +import darkViewsAsset from "@/app/assets/empty-state/disabled-feature/views-dark.webp?url"; +import lightViewsAsset from "@/app/assets/empty-state/disabled-feature/views-light.webp?url"; +// components +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { ViewAppliedFiltersList } from "@/components/views/applied-filters"; +import { ProjectViewsList } from "@/components/views/views-list"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useProjectView } from "@/hooks/store/use-project-view"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import type { Route } from "./+types/page"; + +function ProjectViewsPage({ params }: Route.ComponentProps) { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = params; + // theme hook + const { resolvedTheme } = useTheme(); + // plane hooks + const { t } = useTranslation(); + // store + const { getProjectById, currentProjectDetails } = useProject(); + const { filters, updateFilters, clearAllFilters } = useProjectView(); + const { allowPermissions } = useUserPermissions(); + // derived values + const project = getProjectById(projectId); + const pageTitle = project?.name ? `${project?.name} - ${t("views")}` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = resolvedTheme === "light" ? lightViewsAsset : darkViewsAsset; + + const handleRemoveFilter = useCallback( + (key: keyof TViewFilterProps, value: string | EViewAccess | null) => { + let newValues = filters.filters?.[key]; + + if (key === "favorites") { + newValues = !!value; + } + if (Array.isArray(newValues)) { + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value) as string[]; + } + + updateFilters("filters", { [key]: newValues }); + }, + [filters.filters, updateFilters] + ); + + const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0; + + // No access to + if (currentProjectDetails?.issue_views_view === false) + return ( +
+ { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
+ ); + + return ( + <> + + {isFiltersApplied && ( +
+ +
+ )} + + + ); +} + +export default observer(ProjectViewsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx new file mode 100644 index 0000000..1e81fe8 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import { Outlet } from "react-router"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local components +import { ProjectsListHeader } from "@/plane-web/components/projects/header"; +import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header"; + +export default function ProjectListLayout() { + return ( + <> + } mobileHeader={} /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx new file mode 100644 index 0000000..3706d80 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { ProjectPageRoot } from "@/plane-web/components/projects/page"; + +function ProjectsPage() { + return ; +} + +export default ProjectsPage; diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx new file mode 100644 index 0000000..53308d8 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +// local components +import { ProjectsListHeader } from "@/plane-web/components/projects/header"; +import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header"; + +export default function ProjectListLayout() { + return ( + <> + } mobileHeader={} /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx new file mode 100644 index 0000000..3706d80 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { ProjectPageRoot } from "@/plane-web/components/projects/page"; + +function ProjectsPage() { + return ; +} + +export default ProjectsPage; diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx new file mode 100644 index 0000000..5279362 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { isEmpty } from "lodash-es"; +import { observer } from "mobx-react"; +// plane helpers +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components +import { SidebarWrapper } from "@/components/sidebar/sidebar-wrapper"; +import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu"; +import { SidebarProjectsList } from "@/components/workspace/sidebar/projects-list"; +import { SidebarQuickActions } from "@/components/workspace/sidebar/quick-actions"; +import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items"; +// hooks +import { useFavorite } from "@/hooks/store/use-favorite"; +import { useUserPermissions } from "@/hooks/store/user"; +// plane web components +import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list"; + +export const AppSidebar = observer(function AppSidebar() { + const { t } = useTranslation(); + // store hooks + const { allowPermissions } = useUserPermissions(); + const { groupedFavorites } = useFavorite(); + + // derived values + const canPerformWorkspaceMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const isFavoriteEmpty = isEmpty(groupedFavorites); + + return ( + }> + + {/* Favorites Menu */} + {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } + {/* Teams List */} + + {/* Projects List */} + + + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx new file mode 100644 index 0000000..c4039f7 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useTheme } from "next-themes"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// assets +import githubBlackImage from "@/app/assets/logos/github-black.png?url"; +import githubWhiteImage from "@/app/assets/logos/github-white.png?url"; + +export function StarUsOnGitHubLink() { + // plane hooks + const { t } = useTranslation(); + // hooks + const { resolvedTheme } = useTheme(); + const imageSrc = resolvedTheme === "dark" ? githubWhiteImage : githubBlackImage; + + return ( + + + {t("home.star_us_on_github")} + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx new file mode 100644 index 0000000..7eab6a4 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { Button } from "@plane/propel/button"; +import { RecentStickyIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { StickySearch } from "@/components/stickies/modal/search"; +import { useStickyOperations } from "@/components/stickies/sticky/use-operations"; +// hooks +import { useSticky } from "@/hooks/use-stickies"; + +export const WorkspaceStickyHeader = observer(function WorkspaceStickyHeader() { + const { workspaceSlug } = useParams(); + // hooks + const { creatingSticky, toggleShowNewSticky } = useSticky(); + const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() }); + + return ( + <> +
+ +
+ + } + /> + } + /> + +
+
+ + + + + +
+ + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx new file mode 100644 index 0000000..14be62a --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { WorkspaceStickyHeader } from "./header"; + +export default function WorkspaceStickiesLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx new file mode 100644 index 0000000..d925b2c --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import { PageHead } from "@/components/core/page-title"; +import { StickiesInfinite } from "@/components/stickies/layout/stickies-infinite"; + +export default function WorkspaceStickiesPage() { + return ( + <> + +
+ +
+ + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx new file mode 100644 index 0000000..3d62ade --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants"; +// components +import { PageHead } from "@/components/core/page-title"; +import { AllIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/all-issue-layout-root"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +import type { Route } from "./+types/page"; + +function GlobalViewIssuesPage({ params }: Route.ComponentProps) { + // router + const { globalViewId } = params; + // store hooks + const { currentWorkspace } = useWorkspace(); + // states + const [isLoading, setIsLoading] = useState(false); + + // derived values + const defaultView = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId); + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - All Views` : undefined; + + // handlers + const toggleLoading = (value: boolean) => setIsLoading(value); + return ( + <> + + + + ); +} + +export default observer(GlobalViewIssuesPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx new file mode 100644 index 0000000..0c6ebe3 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -0,0 +1,191 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + GLOBAL_VIEW_TRACKER_ELEMENTS, + DEFAULT_GLOBAL_VIEWS_LIST, +} from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { ViewsIcon } from "@plane/propel/icons"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, ICustomSearchSelectOption } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; +import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SwitcherLabel } from "@/components/common/switcher-label"; +import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; +import { DefaultWorkspaceViewQuickActions } from "@/components/workspace/views/default-view-quick-action"; +import { CreateUpdateWorkspaceViewModal } from "@/components/workspace/views/modal"; +import { WorkspaceViewQuickActions } from "@/components/workspace/views/quick-action"; +// hooks +import { useGlobalView } from "@/hooks/store/use-global-view"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { GlobalViewLayoutSelection } from "@/plane-web/components/views/helper"; + +export const GlobalIssuesHeader = observer(function GlobalIssuesHeader() { + // states + const [createViewModal, setCreateViewModal] = useState(false); + // router + const router = useAppRouter(); + const { workspaceSlug, globalViewId: routerGlobalViewId } = useParams(); + const globalViewId = routerGlobalViewId ? routerGlobalViewId.toString() : undefined; + // store hooks + const { + issuesFilter: { filters, updateFilters }, + } = useIssues(EIssuesStoreType.GLOBAL); + const { getViewDetailsById, currentWorkspaceViews } = useGlobalView(); + const { t } = useTranslation(); + + const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined; + + const activeLayout = issueFilters?.displayFilters?.layout; + const viewDetails = globalViewId ? getViewDetailsById(globalViewId) : undefined; + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !globalViewId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + globalViewId + ); + }, + [workspaceSlug, updateFilters, globalViewId] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !globalViewId) return; + updateFilters(workspaceSlug.toString(), undefined, EIssueFilterType.DISPLAY_PROPERTIES, property, globalViewId); + }, + [workspaceSlug, updateFilters, globalViewId] + ); + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !globalViewId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout }, + globalViewId + ); + }, + [workspaceSlug, updateFilters, globalViewId] + ); + + const isLocked = viewDetails?.is_locked; + + const isDefaultView = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId); + + const defaultViewDetails = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId); + + const defaultOptions = DEFAULT_GLOBAL_VIEWS_LIST.map((view) => ({ + value: view.key, + query: view.key, + content: , + })); + + const workspaceOptions = (currentWorkspaceViews || []).map((view) => { + const _view = getViewDetailsById(view); + if (!_view) return; + return { + value: _view.id, + query: _view.name, + content: , + }; + }); + + const switcherOptions = [...defaultOptions, ...workspaceOptions].filter( + (option) => option !== undefined + ) as ICustomSearchSelectOption[]; + const currentLayoutFilters = useMemo(() => { + const layout = activeLayout ?? EIssueLayoutTypes.SPREADSHEET; + return ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.layoutOptions[layout]; + }, [activeLayout]); + + return ( + <> + setCreateViewModal(false)} /> +
+ + + } />} + /> + { + router.push(`/${workspaceSlug}/workspace-views/${value}`); + }} + title={viewDetails?.name ?? t(defaultViewDetails?.i18n_label ?? "")} + icon={ + + + + } + isLast + /> + } + isLast + /> + + + + + {!isLocked && ( + + )} + {globalViewId && } + {!isLocked && ( + + + + )} + +
+ {viewDetails && } + {isDefaultView && defaultViewDetails && ( + + )} +
+
+
+ + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx new file mode 100644 index 0000000..60f6e5a --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { GlobalIssuesHeader } from "./header"; + +export default function GlobalIssuesLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx new file mode 100644 index 0000000..8043f47 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { SearchIcon } from "@plane/propel/icons"; +import { Input } from "@plane/ui"; +// components +import { PageHead } from "@/components/core/page-title"; +import { GlobalDefaultViewListItem } from "@/components/workspace/views/default-view-list-item"; +import { GlobalViewsList } from "@/components/workspace/views/views-list"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; + +function WorkspaceViewsPage() { + const [query, setQuery] = useState(""); + // store + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - All Views` : undefined; + + return ( + <> + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + mode="true-transparent" + /> +
+
+ {DEFAULT_GLOBAL_VIEWS_LIST.filter((v) => t(v.i18n_label).toLowerCase().includes(query.toLowerCase())).map( + (option) => ( + + ) + )} + +
+
+ + ); +} + +export default observer(WorkspaceViewsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx new file mode 100644 index 0000000..a8d2c4b --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider"; + +export default function SettingsLayout() { + return ( + <> + +
+
+ {/* Content */} + +
+ +
+
+
+
+ + ); +} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/header.tsx new file mode 100644 index 0000000..6172567 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const BillingWorkspaceSettingsHeader = observer(function BillingWorkspaceSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS["billing-and-plans"]; + const Icon = WORKSPACE_SETTINGS_ICONS["billing-and-plans"]; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx new file mode 100644 index 0000000..cd37a2e --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { useAppRouter } from "@/hooks/use-app-router"; + +function BillingSettingsPage() { + const router = useAppRouter(); + const { workspaceSlug } = useParams(); + + useEffect(() => { + if (workspaceSlug) { + router.replace(`/${workspaceSlug}/settings`); + } + }, [router, workspaceSlug]); + + return ( +
+ +
+ ); +} + +export default observer(BillingSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/header.tsx new file mode 100644 index 0000000..2cc08a4 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const ExportsWorkspaceSettingsHeader = observer(function ExportsWorkspaceSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS.export; + const Icon = WORKSPACE_SETTINGS_ICONS.export; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx new file mode 100644 index 0000000..af6141b --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { cn } from "@plane/utils"; +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { ExportGuide } from "@/components/exporter/guide"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import { ExportsWorkspaceSettingsHeader } from "./header"; + +function ExportsPage() { + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + + // derived values + const canPerformWorkspaceMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.exports.title")}` + : undefined; + + // if user is not authorized to view this page + if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { + return ; + } + + return ( + } hugging> + +
+ + +
+
+ ); +} + +export default observer(ExportsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/header.tsx new file mode 100644 index 0000000..7a69bb2 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const GeneralWorkspaceSettingsHeader = observer(function GeneralWorkspaceSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS.general; + const Icon = WORKSPACE_SETTINGS_ICONS.general; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx new file mode 100644 index 0000000..ea32086 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import useSWR from "swr"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SingleIntegrationCard } from "@/components/integration/single-integration-card"; +import { IntegrationAndImportExportBanner } from "@/components/ui/integration-and-import-export-banner"; +import { IntegrationsSettingsLoader } from "@/components/ui/loader/settings/integration"; +// constants +import { APP_INTEGRATIONS } from "@/constants/fetch-keys"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +// services +import { IntegrationService } from "@/services/integrations"; + +const integrationService = new IntegrationService(); + +function WorkspaceIntegrationsPage() { + // store hooks + const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); + + // derived values + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined; + const { data: appIntegrations } = useSWR(isAdmin ? APP_INTEGRATIONS : null, () => + isAdmin ? integrationService.getAppIntegrationsList() : null + ); + + if (!isAdmin) return ; + + return ( + <> + +
+ +
+ {appIntegrations ? ( + appIntegrations.map((integration) => ( + + )) + ) : ( + + )} +
+
+ + ); +} + +export default observer(WorkspaceIntegrationsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx new file mode 100644 index 0000000..a57df1e --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +import { Outlet } from "react-router"; +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper"; +import { SettingsMobileNav } from "@/components/settings/mobile/nav"; +// plane imports +import { WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; +import type { EUserWorkspaceRoles } from "@plane/types"; +// components +import { WorkspaceSettingsSidebarRoot } from "@/components/settings/workspace/sidebar"; +// hooks +import { useUserPermissions } from "@/hooks/store/user"; + +import type { Route } from "./+types/layout"; + +const WorkspaceSettingLayout = observer(function WorkspaceSettingLayout({ params }: Route.ComponentProps) { + // router + const { workspaceSlug } = params; + // store hooks + const { workspaceUserInfo, getWorkspaceRoleByWorkspaceSlug } = useUserPermissions(); + // next hooks + const pathname = usePathname(); + // derived values + const { accessKey } = pathnameToAccessKey(pathname); + const userWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug); + + let isAuthorized: boolean | string = false; + if (pathname && workspaceSlug && userWorkspaceRole) { + isAuthorized = WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles); + } + + return ( + <> + +
+ {workspaceUserInfo && !isAuthorized ? ( + + ) : ( +
+
+ +
+ +
+ )} +
+ + ); +}); + +export default WorkspaceSettingLayout; diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/header.tsx new file mode 100644 index 0000000..243f8da --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const MembersWorkspaceSettingsHeader = observer(function MembersWorkspaceSettingsHeader() { + // plane hooks + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS.members; + const Icon = WORKSPACE_SETTINGS_ICONS.members; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx new file mode 100644 index 0000000..1e09ef3 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx @@ -0,0 +1,155 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +// types +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { SearchIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IWorkspaceBulkInviteFormData } from "@plane/types"; +import { cn } from "@plane/utils"; +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { CountChip } from "@/components/common/count-chip"; +import { PageHead } from "@/components/core/page-title"; +import { MemberListFiltersDropdown } from "@/components/project/dropdowns/filters/member-list"; +import { WorkspaceMembersList } from "@/components/workspace/settings/members-list"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +// plane web components +import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button"; +import { SendWorkspaceInvitationModal, MembersActivityButton } from "@/plane-web/components/workspace/members"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +// local imports +import type { Route } from "./+types/page"; +import { MembersWorkspaceSettingsHeader } from "./header"; + +const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsPage({ params }: Route.ComponentProps) { + // states + const [inviteModal, setInviteModal] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + // router + const { workspaceSlug } = params; + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { + workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore }, + } = useMember(); + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const canPerformWorkspaceMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const handleWorkspaceInvite = async (data: IWorkspaceBulkInviteFormData) => { + try { + await inviteMembersToWorkspace(workspaceSlug, data); + + setInviteModal(false); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: t("workspace_settings.settings.members.invitations_sent_successfully"), + }); + } catch (error: unknown) { + let message = undefined; + if (error instanceof Error) { + const err = error as Error & { error?: string }; + message = err.error; + } + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${message ?? t("something_went_wrong_please_try_again")}`, + }); + + throw error; + } + }; + + // Handler for role filter updates + const handleRoleFilterUpdate = (role: string) => { + const currentFilters = filtersStore.filters; + const currentRoles = currentFilters?.roles || []; + const updatedRoles = currentRoles.includes(role) ? currentRoles.filter((r) => r !== role) : [...currentRoles, role]; + + filtersStore.updateFilters({ + roles: updatedRoles.length > 0 ? updatedRoles : undefined, + }); + }; + + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Members` : undefined; + const appliedRoleFilters = filtersStore.filters?.roles || []; + + // if user is not authorized to view this page + if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { + return ; + } + + return ( + } hugging> + + setInviteModal(false)} + onSubmit={handleWorkspaceInvite} + /> +
+
+

+ {t("workspace_settings.settings.members.title")} + {workspaceMemberIds && workspaceMemberIds.length > 0 && ( + + )} +

+
+
+ + setSearchQuery(e.target.value)} + /> +
+ + + {canPerformWorkspaceAdminActions && ( + + )} + +
+
+ +
+
+ ); +}); + +export default WorkspaceMembersSettingsPage; diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx new file mode 100644 index 0000000..55422ec --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +// local imports +import { GeneralWorkspaceSettingsHeader } from "./header"; + +function GeneralWorkspaceSettingsPage() { + // store hooks + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + // derived values + const pageTitle = currentWorkspace?.name + ? t("workspace_settings.page_label", { workspace: currentWorkspace.name }) + : undefined; + + return ( + }> + + + + ); +} + +export default observer(GeneralWorkspaceSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/header.tsx new file mode 100644 index 0000000..7c24c6b --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const WebhookDetailsWorkspaceSettingsHeader = observer(function WebhookDetailsWorkspaceSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS.webhooks; + const Icon = WORKSPACE_SETTINGS_ICONS.webhooks; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx new file mode 100644 index 0000000..5cb9d3c --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IWebhook } from "@plane/types"; +// ui +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks"; +// hooks +import { useWebhook } from "@/hooks/store/use-webhook"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { WebhookDetailsWorkspaceSettingsHeader } from "./header"; + +function WebhookDetailsPage({ params }: Route.ComponentProps) { + // states + const [deleteWebhookModal, setDeleteWebhookModal] = useState(false); + // router + const { workspaceSlug, webhookId } = params; + // mobx store + const { currentWebhook, fetchWebhookById, updateWebhook } = useWebhook(); + const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); + + // TODO: fix this error + // useEffect(() => { + // if (isCreated !== "true") clearSecretKey(); + // }, [clearSecretKey, isCreated]); + // derived values + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Webhook` : undefined; + + useSWR( + isAdmin ? `WEBHOOK_DETAILS_${workspaceSlug}_${webhookId}` : null, + isAdmin ? () => fetchWebhookById(workspaceSlug, webhookId) : null + ); + + const handleUpdateWebhook = async (formData: IWebhook) => { + if (!formData || !formData.id) return; + + const payload = { + url: formData.url, + is_active: formData.is_active, + project: formData.project, + cycle: formData.cycle, + module: formData.module, + issue: formData.issue, + issue_comment: formData.issue_comment, + }; + + try { + await updateWebhook(workspaceSlug, formData.id, payload); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Webhook updated successfully.", + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: error?.error ?? "Something went wrong. Please try again.", + }); + } + }; + + if (!isAdmin) + return ( + <> + +
+

You are not authorized to access this page.

+
+ + ); + + if (!currentWebhook) + return ( +
+ +
+ ); + + return ( + }> + + setDeleteWebhookModal(false)} /> +
+
+ +
+ {currentWebhook && setDeleteWebhookModal(true)} />} +
+
+ ); +} + +export default observer(WebhookDetailsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/header.tsx new file mode 100644 index 0000000..0916bf9 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const WebhooksWorkspaceSettingsHeader = observer(function WebhooksWorkspaceSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS.webhooks; + const Icon = WORKSPACE_SETTINGS_ICONS.webhooks; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx new file mode 100644 index 0000000..099a0ee --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +// components +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsHeading } from "@/components/settings/heading"; +import { WebhookSettingsLoader } from "@/components/ui/loader/settings/web-hook"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks"; +// hooks +import { useWebhook } from "@/hooks/store/use-webhook"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { WebhooksWorkspaceSettingsHeader } from "./header"; + +function WebhooksListPage({ params }: Route.ComponentProps) { + // states + const [showCreateWebhookModal, setShowCreateWebhookModal] = useState(false); + // router + const { workspaceSlug } = params; + // plane hooks + const { t } = useTranslation(); + // mobx store + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { fetchWebhooks, webhooks, clearSecretKey, webhookSecretKey, createWebhook } = useWebhook(); + const { currentWorkspace } = useWorkspace(); + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + + useSWR( + canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null, + canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug) : null + ); + + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.webhooks.title")}` + : undefined; + + // clear secret key when modal is closed. + useEffect(() => { + if (!showCreateWebhookModal && webhookSecretKey) clearSecretKey(); + }, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]); + + if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { + return ; + } + + if (!webhooks) return ; + + return ( + }> + +
+ { + setShowCreateWebhookModal(false); + }} + /> + setShowCreateWebhookModal(true)}> + {t("workspace_settings.settings.webhooks.add_webhook")} + + } + /> + {Object.keys(webhooks).length > 0 ? ( +
+ +
+ ) : ( +
+
+ { + setShowCreateWebhookModal(true); + }, + }, + ]} + align="start" + rootClassName="py-20" + /> +
+
+ )} +
+
+ ); +} + +export default observer(WebhooksListPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/header.tsx new file mode 100644 index 0000000..df9ae6e --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const AutomationsProjectSettingsHeader = observer(function AutomationsProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.automations; + const Icon = PROJECT_SETTINGS_ICONS.automations; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx new file mode 100644 index 0000000..a4d766c --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { Outlet } from "react-router"; +// plane web imports +import { AutomationsListWrapper } from "@/plane-web/components/automations/list/wrapper"; +import type { Route } from "./+types/layout"; + +function AutomationsListLayout({ params }: Route.ComponentProps) { + const { projectId, workspaceSlug } = params; + + return ( + + + + ); +} + +export default observer(AutomationsListLayout); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx new file mode 100644 index 0000000..b591e91 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IProject } from "@plane/types"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// plane web imports +import { CustomAutomationsRoot } from "@/plane-web/components/automations/root"; +// local imports +import type { Route } from "./+types/page"; +import { AutomationsProjectSettingsHeader } from "./header"; + +function AutomationSettingsPage({ params }: Route.ComponentProps) { + // router + const { workspaceSlug, projectId } = params; + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails: projectDetails, updateProject } = useProject(); + + const { t } = useTranslation(); + + // derived values + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + const handleChange = async (formData: Partial) => { + if (!projectDetails) return; + + try { + await updateProject(workspaceSlug, projectId, formData); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Something went wrong. Please try again.", + }); + } + }; + + // derived values + const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined; + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + } hugging> + +
+ +
+ + +
+
+ +
+ ); +} + +export default observer(AutomationSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/header.tsx new file mode 100644 index 0000000..447f925 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const EstimatesProjectSettingsHeader = observer(function EstimatesProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.estimates; + const Icon = PROJECT_SETTINGS_ICONS.estimates; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx new file mode 100644 index 0000000..cfa1e94 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { EstimateRoot } from "@/components/estimates"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { EstimatesProjectSettingsHeader } from "./header"; + +function EstimatesSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // store + const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + + // derived values + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + }> + +
+ +
+
+ ); +} + +export default observer(EstimatesSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/header.tsx new file mode 100644 index 0000000..c2fa9de --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const FeaturesCyclesProjectSettingsHeader = observer(function FeaturesCyclesProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.features_cycles; + const Icon = PROJECT_SETTINGS_ICONS.features_cycles; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx new file mode 100644 index 0000000..b86a1b2 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { FeaturesCyclesProjectSettingsHeader } from "./header"; +import { SettingsHeading } from "@/components/settings/heading"; + +function FeaturesCyclesSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); + // translation + const { t } = useTranslation(); + // derived values + const pageTitle = currentProjectDetails?.name + ? `${currentProjectDetails?.name} settings - ${t("project_settings.features.cycles.short_title")}` + : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + }> + +
+ +
+ +
+
+
+ ); +} + +export default observer(FeaturesCyclesSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/header.tsx new file mode 100644 index 0000000..5f81d40 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const FeaturesIntakeProjectSettingsHeader = observer(function FeaturesIntakeProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.features_intake; + const Icon = PROJECT_SETTINGS_ICONS.features_intake; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx new file mode 100644 index 0000000..3349a79 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { FeaturesIntakeProjectSettingsHeader } from "./header"; + +function FeaturesIntakeSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); + // translation + const { t } = useTranslation(); + // derived values + const pageTitle = currentProjectDetails?.name + ? `${currentProjectDetails?.name} settings - ${t("project_settings.features.intake.short_title")}` + : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + }> + +
+ +
+ +
+
+
+ ); +} + +export default observer(FeaturesIntakeSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/header.tsx new file mode 100644 index 0000000..baf32b2 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const FeaturesModulesProjectSettingsHeader = observer(function FeaturesModulesProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.features_modules; + const Icon = PROJECT_SETTINGS_ICONS.features_modules; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx new file mode 100644 index 0000000..1253f0d --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { FeaturesModulesProjectSettingsHeader } from "./header"; + +function FeaturesModulesSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); + // translation + const { t } = useTranslation(); + // derived values + const pageTitle = currentProjectDetails?.name + ? `${currentProjectDetails?.name} settings - ${t("project_settings.features.modules.short_title")}` + : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + }> + +
+ +
+ +
+
+
+ ); +} + +export default observer(FeaturesModulesSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/header.tsx new file mode 100644 index 0000000..fa7a40e --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const FeaturesPagesProjectSettingsHeader = observer(function FeaturesPagesProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.features_pages; + const Icon = PROJECT_SETTINGS_ICONS.features_pages; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx new file mode 100644 index 0000000..2b018e5 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { FeaturesPagesProjectSettingsHeader } from "./header"; + +function FeaturesPagesSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); + // translation + const { t } = useTranslation(); + // derived values + const pageTitle = currentProjectDetails?.name + ? `${currentProjectDetails?.name} settings - ${t("project_settings.features.pages.short_title")}` + : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + }> + +
+ +
+ +
+
+
+ ); +} + +export default observer(FeaturesPagesSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/header.tsx new file mode 100644 index 0000000..c8429d9 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const FeaturesViewsProjectSettingsHeader = observer(function FeaturesViewsProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.features_views; + const Icon = PROJECT_SETTINGS_ICONS.features_views; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx new file mode 100644 index 0000000..594028f --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { FeaturesViewsProjectSettingsHeader } from "./header"; + +function FeaturesViewsSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); + // translation + const { t } = useTranslation(); + // derived values + const pageTitle = currentProjectDetails?.name + ? `${currentProjectDetails?.name} settings - ${t("project_settings.features.views.short_title")}` + : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + }> + +
+ +
+ +
+
+
+ ); +} + +export default observer(FeaturesViewsSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/header.tsx new file mode 100644 index 0000000..95d4720 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const GeneralProjectSettingsHeader = observer(function GeneralProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.general; + const Icon = PROJECT_SETTINGS_ICONS.general; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/header.tsx new file mode 100644 index 0000000..b9daaae --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const LabelsProjectSettingsHeader = observer(function LabelsProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.labels; + const Icon = PROJECT_SETTINGS_ICONS.labels; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx new file mode 100644 index 0000000..870c05f --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect, useRef } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; +import { observer } from "mobx-react"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { ProjectSettingsLabelList } from "@/components/labels"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import { LabelsProjectSettingsHeader } from "./header"; + +function LabelsSettingsPage() { + // store hooks + const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined; + + const scrollableContainerRef = useRef(null); + + // derived values + const canPerformProjectMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + // Enable Auto Scroll for Labels list + useEffect(() => { + const element = scrollableContainerRef.current; + + if (!element) return; + + return combine( + autoScrollForElements({ + element, + }) + ); + }, []); + + if (workspaceUserInfo && !canPerformProjectMemberActions) { + return ; + } + + return ( + }> + +
+ +
+
+ ); +} + +export default observer(LabelsSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx new file mode 100644 index 0000000..6ebe9df --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +import { Outlet } from "react-router"; +// components +import { getProjectActivePath } from "@/components/settings/helper"; +import { SettingsMobileNav } from "@/components/settings/mobile/nav"; +// layouts +import { ProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper"; +// types +import type { Route } from "./+types/layout"; +import { ProjectSettingsSidebarRoot } from "@/components/settings/project/sidebar"; + +function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // router + const pathname = usePathname(); + + return ( + <> + } + activePath={getProjectActivePath(pathname) || ""} + /> +
+
+
+ +
+ + + +
+
+ + ); +} + +export default observer(ProjectDetailSettingsLayout); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/header.tsx new file mode 100644 index 0000000..e8e3c4f --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const MembersProjectSettingsHeader = observer(function MembersProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.members; + const Icon = PROJECT_SETTINGS_ICONS.members; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx new file mode 100644 index 0000000..61abee5 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { ProjectMemberList } from "@/components/project/member-list"; +import { ProjectSettingsMemberDefaults } from "@/components/project/project-settings-member-defaults"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// plane web imports +import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list"; +// local imports +import type { Route } from "./+types/page"; +import { MembersProjectSettingsHeader } from "./header"; + +function MembersSettingsPage({ params }: Route.ComponentProps) { + // router + const { workspaceSlug, projectId } = params; + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + // derived values + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined; + const isProjectMemberOrAdmin = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin; + + if (workspaceUserInfo && !canPerformProjectMemberActions) { + return ; + } + + return ( + } hugging> + + + + + + + ); +} + +export default observer(MembersSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx new file mode 100644 index 0000000..39a8046 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +// components +import { PageHead } from "@/components/core/page-title"; +import { ProjectDetailsForm } from "@/components/project/form"; +import { ProjectDetailsFormLoader } from "@/components/project/form-loader"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { GeneralProjectSettingsHeader } from "./header"; +import { GeneralProjectSettingsControlSection } from "@/components/project/settings/control-section"; + +function ProjectSettingsPage({ params }: Route.ComponentProps) { + // router + const { workspaceSlug, projectId } = params; + // store hooks + const { currentProjectDetails } = useProject(); + const { allowPermissions } = useUserPermissions(); + // derived values + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId); + + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined; + + return ( + }> + +
+ {currentProjectDetails ? ( + + ) : ( + + )} + {isAdmin && } +
+
+ ); +} + +export default observer(ProjectSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/header.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/header.tsx new file mode 100644 index 0000000..957632b --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/header.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const StatesProjectSettingsHeader = observer(function StatesProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.states; + const Icon = PROJECT_SETTINGS_ICONS.states; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx new file mode 100644 index 0000000..9913d52 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { ProjectStateRoot } from "@/components/project-states"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +// hook +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { StatesProjectSettingsHeader } from "./header"; + +function StatesSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // store + const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + + const { t } = useTranslation(); + + // derived values + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined; + // derived values + const canPerformProjectMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + if (workspaceUserInfo && !canPerformProjectMemberActions) { + return ; + } + + return ( + }> + +
+ +
+ +
+
+
+ ); +} + +export default observer(StatesSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx new file mode 100644 index 0000000..86229fe --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { Outlet } from "react-router"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +// types +import type { Route } from "./+types/layout"; + +function ProjectSettingsLayout({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // router + const router = useAppRouter(); + // store hooks + const { joinedProjectIds } = useProject(); + + useEffect(() => { + if (projectId) return; + if (joinedProjectIds.length > 0) { + router.push(`/${workspaceSlug}/settings/projects/${joinedProjectIds[0]}`); + } + }, [joinedProjectIds, router, workspaceSlug, projectId]); + + return ; +} + +export default observer(ProjectSettingsLayout); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx new file mode 100644 index 0000000..86bd9f8 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +// plane imports +import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { cn } from "@plane/utils"; +// assets +import ProjectDarkEmptyState from "@/app/assets/empty-state/project-settings/no-projects-dark.png?url"; +import ProjectLightEmptyState from "@/app/assets/empty-state/project-settings/no-projects-light.png?url"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; + +function ProjectSettingsPage() { + // store hooks + const { resolvedTheme } = useTheme(); + const { toggleCreateProjectModal } = useCommandPalette(); + // derived values + const resolvedPath = resolvedTheme === "dark" ? ProjectDarkEmptyState : ProjectLightEmptyState; + return ( +
+ No projects yet +
No projects yet
+
+ Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you + need to get things done. +
+
+ + Learn more about projects + + +
+
+ ); +} + +export default observer(ProjectSettingsPage); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx new file mode 100644 index 0000000..2936886 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; +import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/content-wrapper"; +import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail"; +import { GlobalModals } from "@/plane-web/components/common/modal/global"; +import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper"; +import type { Route } from "./+types/layout"; + +export default function WorkspaceLayout(props: Route.ComponentProps) { + const { workspaceSlug } = props.params; + + return ( + + + + + + + + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/accounts/forgot-password/layout.tsx b/plane-src/apps/web/app/(all)/accounts/forgot-password/layout.tsx new file mode 100644 index 0000000..fbae8d5 --- /dev/null +++ b/plane-src/apps/web/app/(all)/accounts/forgot-password/layout.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; + +export default function ForgotPasswordLayout() { + return ; +} + +export const meta: Route.MetaFunction = () => [{ title: "Forgot Password - NODE.DC" }]; diff --git a/plane-src/apps/web/app/(all)/accounts/forgot-password/page.tsx b/plane-src/apps/web/app/(all)/accounts/forgot-password/page.tsx new file mode 100644 index 0000000..eb71311 --- /dev/null +++ b/plane-src/apps/web/app/(all)/accounts/forgot-password/page.tsx @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// components +import { ForgotPasswordForm } from "@/components/account/auth-forms/forgot-password"; +import { AuthHeader } from "@/components/auth-screens/header"; +// helpers +import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +function ForgotPasswordPage() { + return ( + + +
+ + +
+
+
+ ); +} + +export default observer(ForgotPasswordPage); diff --git a/plane-src/apps/web/app/(all)/accounts/reset-password/layout.tsx b/plane-src/apps/web/app/(all)/accounts/reset-password/layout.tsx new file mode 100644 index 0000000..5a04266 --- /dev/null +++ b/plane-src/apps/web/app/(all)/accounts/reset-password/layout.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; + +export default function ResetPasswordLayout() { + return ; +} + +export const meta: Route.MetaFunction = () => [{ title: "Reset Password - NODE.DC" }]; diff --git a/plane-src/apps/web/app/(all)/accounts/reset-password/page.tsx b/plane-src/apps/web/app/(all)/accounts/reset-password/page.tsx new file mode 100644 index 0000000..c9914f0 --- /dev/null +++ b/plane-src/apps/web/app/(all)/accounts/reset-password/page.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// plane imports +import { EAuthModes } from "@plane/constants"; +// components +import { ResetPasswordForm } from "@/components/account/auth-forms/reset-password"; +import { AuthHeader } from "@/components/auth-screens/header"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +function ResetPasswordPage() { + return ( + + +
+ + +
+
+
+ ); +} + +export default ResetPasswordPage; diff --git a/plane-src/apps/web/app/(all)/accounts/set-password/layout.tsx b/plane-src/apps/web/app/(all)/accounts/set-password/layout.tsx new file mode 100644 index 0000000..1efb6e1 --- /dev/null +++ b/plane-src/apps/web/app/(all)/accounts/set-password/layout.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; + +export default function SetPasswordLayout() { + return ; +} + +export const meta: Route.MetaFunction = () => [{ title: "Set Password - NODE.DC" }]; diff --git a/plane-src/apps/web/app/(all)/accounts/set-password/page.tsx b/plane-src/apps/web/app/(all)/accounts/set-password/page.tsx new file mode 100644 index 0000000..493f86d --- /dev/null +++ b/plane-src/apps/web/app/(all)/accounts/set-password/page.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// plane imports +import { EAuthModes } from "@plane/constants"; +// components +import { ResetPasswordForm } from "@/components/account/auth-forms/reset-password"; +import { AuthHeader } from "@/components/auth-screens/header"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +function SetPasswordPage() { + return ( + + +
+ + +
+
+
+ ); +} + +export default SetPasswordPage; diff --git a/plane-src/apps/web/app/(all)/create-workspace/layout.tsx b/plane-src/apps/web/app/(all)/create-workspace/layout.tsx new file mode 100644 index 0000000..95b73bf --- /dev/null +++ b/plane-src/apps/web/app/(all)/create-workspace/layout.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; + +export default function CreateWorkspaceLayout() { + return ; +} + +export const meta: Route.MetaFunction = () => [{ title: "Create Workspace" }]; diff --git a/plane-src/apps/web/app/(all)/create-workspace/page.tsx b/plane-src/apps/web/app/(all)/create-workspace/page.tsx new file mode 100644 index 0000000..3808718 --- /dev/null +++ b/plane-src/apps/web/app/(all)/create-workspace/page.tsx @@ -0,0 +1,115 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { PlaneLogo } from "@plane/propel/icons"; +import type { IWorkspace } from "@plane/types"; +// assets +import WorkspaceCreationDisabled from "@/app/assets/workspace/workspace-creation-disabled.png?url"; +// components +import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form"; +// hooks +import { useUser, useUserProfile } from "@/hooks/store/user"; +import { useInstance } from "@/hooks/store/use-instance"; +import { useAppRouter } from "@/hooks/use-app-router"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +const CreateWorkspacePage = observer(function CreateWorkspacePage() { + const { t } = useTranslation(); + // router + const router = useAppRouter(); + // store hooks + const { config } = useInstance(); + const { data: currentUser } = useUser(); + const { updateUserProfile } = useUserProfile(); + // states + const [defaultValues, setDefaultValues] = useState>({ + name: "", + slug: "", + organization_size: "", + }); + // derived values + const isWorkspaceCreationDisabled = config?.is_workspace_creation_disabled ?? false; + + // methods + const getMailtoHref = () => { + const subject = t("workspace_creation.request_email.subject"); + const body = t("workspace_creation.request_email.body", { + firstName: currentUser?.first_name || "", + lastName: currentUser?.last_name || "", + email: currentUser?.email || "", + }); + + return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + }; + + const onSubmit = async (workspace: IWorkspace) => { + await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`)); + }; + + return ( + +
+
+
+ + + +
+ {currentUser?.email} +
+
+
+ {isWorkspaceCreationDisabled ? ( +
+ Workspace creation disabled +
+ {t("workspace_creation.errors.creation_disabled.title")} +
+

+ {t("workspace_creation.errors.creation_disabled.description")} +

+
+ + + {t("workspace_creation.errors.creation_disabled.request_button")} + +
+
+ ) : ( +
+

{t("workspace_creation.heading")}

+
+ +
+
+ )} +
+
+ + ); +}); + +export default CreateWorkspacePage; diff --git a/plane-src/apps/web/app/(all)/invitations/layout.tsx b/plane-src/apps/web/app/(all)/invitations/layout.tsx new file mode 100644 index 0000000..68f4fe7 --- /dev/null +++ b/plane-src/apps/web/app/(all)/invitations/layout.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; + +export default function InvitationsLayout() { + return ; +} + +export const meta: Route.MetaFunction = () => [{ title: "Invitations" }]; diff --git a/plane-src/apps/web/app/(all)/invitations/page.tsx b/plane-src/apps/web/app/(all)/invitations/page.tsx new file mode 100644 index 0000000..bdf3da4 --- /dev/null +++ b/plane-src/apps/web/app/(all)/invitations/page.tsx @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; + +import useSWR, { mutate } from "swr"; +import { CheckCircle2 } from "lucide-react"; +// plane imports +import { ROLE_DETAILS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// types +import { Button } from "@plane/propel/button"; +import { PlaneLogo } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IWorkspaceMemberInvitation } from "@plane/types"; +import { truncateText } from "@plane/utils"; +// assets +import emptyInvitation from "@/app/assets/empty-state/invitation.svg?url"; +// components +import { EmptyState } from "@/components/common/empty-state"; +import { WorkspaceLogo } from "@/components/workspace/logo"; +import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUser, useUserProfile } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// services +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; +// plane web services +import { WorkspaceService } from "@/services/workspace.service"; + +const workspaceService = new WorkspaceService(); + +function UserInvitationsPage() { + // states + const [invitationsRespond, setInvitationsRespond] = useState([]); + const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); + // router + const router = useAppRouter(); + // store hooks + const { t } = useTranslation(); + const { data: currentUser } = useUser(); + const { updateUserProfile } = useUserProfile(); + + const { fetchWorkspaces } = useWorkspace(); + + const { data: invitations } = useSWR("USER_WORKSPACE_INVITATIONS", () => workspaceService.userWorkspaceInvitations()); + + const redirectWorkspaceSlug = + // currentUserSettings?.workspace?.last_workspace_slug || + // currentUserSettings?.workspace?.fallback_workspace_slug || + ""; + + const handleInvitation = (workspace_invitation: IWorkspaceMemberInvitation, action: "accepted" | "withdraw") => { + if (action === "accepted") { + setInvitationsRespond((prevData) => [...prevData, workspace_invitation.id]); + } else if (action === "withdraw") { + setInvitationsRespond((prevData) => prevData.filter((item: string) => item !== workspace_invitation.id)); + } + }; + + const submitInvitations = () => { + if (invitationsRespond.length === 0) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("please_select_at_least_one_invitation"), + }); + return; + } + + setIsJoiningWorkspaces(true); + + workspaceService + .joinWorkspaces({ invitations: invitationsRespond }) + .then(() => { + mutate(USER_WORKSPACES_LIST); + const firstInviteId = invitationsRespond[0]; + const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace; + updateUserProfile({ last_workspace_id: redirectWorkspace?.id }) + .then(() => { + setIsJoiningWorkspaces(false); + fetchWorkspaces().then(() => { + router.push(`/${redirectWorkspace?.slug}`); + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("something_went_wrong_please_try_again"), + }); + setIsJoiningWorkspaces(false); + }); + }) + .catch((_err) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("something_went_wrong_please_try_again"), + }); + setIsJoiningWorkspaces(false); + }); + }; + + return ( + +
+
+ + + +
{currentUser?.email}
+
+ {invitations ? ( + invitations.length > 0 ? ( +
+
+
+
{t("we_see_that_someone_has_invited_you_to_join_a_workspace")}
+

{t("join_a_workspace")}

+
+
+ {invitations.map((invitation) => { + const isSelected = invitationsRespond.includes(invitation.id); + + return ( +
handleInvitation(invitation, isSelected ? "withdraw" : "accepted")} + > +
+ +
+
+
{truncateText(invitation.workspace.name, 30)}
+

+ {t(ROLE_DETAILS[invitation.role as keyof typeof ROLE_DETAILS]?.i18n_title || "")} +

+
+ + + +
+ ); + })} +
+
+ + + + + + +
+
+
+ ) : ( +
+ router.push("/"), + }} + /> +
+ ) + ) : null} +
+
+ ); +} + +export default observer(UserInvitationsPage); diff --git a/plane-src/apps/web/app/(all)/layout.preload.tsx b/plane-src/apps/web/app/(all)/layout.preload.tsx new file mode 100644 index 0000000..e070eaa --- /dev/null +++ b/plane-src/apps/web/app/(all)/layout.preload.tsx @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// TODO: Check if we need this +// https://nextjs.org/docs/app/api-reference/functions/generate-metadata#link-relpreload +// export const usePreloadResources = () => { +// useEffect(() => { +// const preloadItem = (url: string) => { +// ReactDOM.preload(url, { as: "fetch", crossOrigin: "use-credentials" }); +// }; + +// const urls = [ +// `${process.env.VITE_API_BASE_URL}/api/instances/`, +// `${process.env.VITE_API_BASE_URL}/api/users/me/`, +// `${process.env.VITE_API_BASE_URL}/api/users/me/profile/`, +// `${process.env.VITE_API_BASE_URL}/api/users/me/settings/`, +// `${process.env.VITE_API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`, +// ]; + +// urls.forEach((url) => preloadItem(url)); +// }, []); +// }; + +export function PreloadResources() { + return ( + // usePreloadResources(); + null + ); +} diff --git a/plane-src/apps/web/app/(all)/layout.tsx b/plane-src/apps/web/app/(all)/layout.tsx new file mode 100644 index 0000000..ab06034 --- /dev/null +++ b/plane-src/apps/web/app/(all)/layout.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; +import { PreloadResources } from "./layout.preload"; + +export const meta: Route.MetaFunction = () => [ + { name: "robots", content: "noindex, nofollow" }, + { name: "viewport", content: "width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover" }, +]; + +export default function AppLayout() { + return ( + <> + + + + ); +} diff --git a/plane-src/apps/web/app/(all)/onboarding/layout.tsx b/plane-src/apps/web/app/(all)/onboarding/layout.tsx new file mode 100644 index 0000000..fee10f7 --- /dev/null +++ b/plane-src/apps/web/app/(all)/onboarding/layout.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; + +export default function OnboardingLayout() { + return ; +} + +export const meta: Route.MetaFunction = () => [{ title: "Onboarding" }]; diff --git a/plane-src/apps/web/app/(all)/onboarding/page.tsx b/plane-src/apps/web/app/(all)/onboarding/page.tsx new file mode 100644 index 0000000..66f9c06 --- /dev/null +++ b/plane-src/apps/web/app/(all)/onboarding/page.tsx @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import useSWR from "swr"; +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { OnboardingRoot } from "@/components/onboarding"; +import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; +import { EPageTypes } from "@/helpers/authentication.helper"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUser } from "@/hooks/store/user"; +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +function OnboardingPage() { + const { data: user } = useUser(); + const { fetchWorkspaces } = useWorkspace(); + + useSWR(USER_WORKSPACES_LIST, () => { + if (user?.id) { + fetchWorkspaces(); + } + }); + + return ( + +
+
+
+ {user ? ( + + ) : ( +
+ +
+ )} +
+
+
+
+ ); +} + +export default observer(OnboardingPage); diff --git a/plane-src/apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx b/plane-src/apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx new file mode 100644 index 0000000..d7df27c --- /dev/null +++ b/plane-src/apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane imports +import { PROFILE_SETTINGS_TABS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { TProfileSettingsTabs } from "@plane/types"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PageHead } from "@/components/core/page-title"; +import { ProfileSettingsContent } from "@/components/settings/profile/content"; +import { ProfileSettingsSidebarRoot } from "@/components/settings/profile/sidebar"; +// hooks +import { useUser } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// local imports +import type { Route } from "../+types/layout"; + +function ProfileSettingsPage(props: Route.ComponentProps) { + const { profileTabId } = props.params; + // router + const router = useAppRouter(); + // store hooks + const { data: currentUser } = useUser(); + // translation + const { t } = useTranslation(); + // derived values + const isAValidTab = PROFILE_SETTINGS_TABS.includes(profileTabId as TProfileSettingsTabs); + + if (!currentUser || !isAValidTab) + return ( +
+ +
+ ); + + return ( + <> + +
+
+ router.push(`/settings/profile/${tab}`)} + /> + +
+
+ + ); +} + +export default observer(ProfileSettingsPage); diff --git a/plane-src/apps/web/app/(all)/settings/profile/layout.tsx b/plane-src/apps/web/app/(all)/settings/profile/layout.tsx new file mode 100644 index 0000000..a30518f --- /dev/null +++ b/plane-src/apps/web/app/(all)/settings/profile/layout.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// components +import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider"; +// lib +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +export default function ProfileSettingsLayout() { + return ( + <> + + +
+
+
+ +
+
+
+
+ + ); +} diff --git a/plane-src/apps/web/app/(all)/sign-up/layout.tsx b/plane-src/apps/web/app/(all)/sign-up/layout.tsx new file mode 100644 index 0000000..2db7164 --- /dev/null +++ b/plane-src/apps/web/app/(all)/sign-up/layout.tsx @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; + +export const meta: Route.MetaFunction = () => [ + { title: "Sign up - NODE.DC" }, + { name: "robots", content: "index, nofollow" }, +]; + +export default function SignUpLayout() { + return ; +} diff --git a/plane-src/apps/web/app/(all)/sign-up/page.tsx b/plane-src/apps/web/app/(all)/sign-up/page.tsx new file mode 100644 index 0000000..639d765 --- /dev/null +++ b/plane-src/apps/web/app/(all)/sign-up/page.tsx @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import { AuthBase } from "@/components/auth-screens/auth-base"; +// helpers +import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; +// assets +import DefaultLayout from "@/layouts/default-layout"; +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +function SignUpPage() { + return ( + + + + + + ); +} + +export default SignUpPage; diff --git a/plane-src/apps/web/app/(all)/workspace-invitations/layout.tsx b/plane-src/apps/web/app/(all)/workspace-invitations/layout.tsx new file mode 100644 index 0000000..b9a7337 --- /dev/null +++ b/plane-src/apps/web/app/(all)/workspace-invitations/layout.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import type { Route } from "./+types/layout"; + +export default function WorkspaceInvitationsLayout() { + return ; +} + +export const meta: Route.MetaFunction = () => [{ title: "Workspace Invitations" }]; diff --git a/plane-src/apps/web/app/(all)/workspace-invitations/page.tsx b/plane-src/apps/web/app/(all)/workspace-invitations/page.tsx new file mode 100644 index 0000000..fbb8b2b --- /dev/null +++ b/plane-src/apps/web/app/(all)/workspace-invitations/page.tsx @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import useSWR from "swr"; +import { Boxes, User2 } from "lucide-react"; +import { CheckIcon, CloseIcon } from "@plane/propel/icons"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { EmptySpace, EmptySpaceItem } from "@/components/ui/empty-space"; +// constants +import { WORKSPACE_INVITATION } from "@/constants/fetch-keys"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +// hooks +import { useUser } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; +import { WorkspaceService } from "@/services/workspace.service"; +// services + +// service initialization +const workspaceService = new WorkspaceService(); + +function WorkspaceInvitationPage() { + // router + const router = useAppRouter(); + // query params + const searchParams = useSearchParams(); + const invitation_id = searchParams.get("invitation_id"); + const slug = searchParams.get("slug"); + const token = searchParams.get("token"); + // store hooks + const { data: currentUser } = useUser(); + + const { data: invitationDetail, error } = useSWR( + invitation_id && slug && WORKSPACE_INVITATION(invitation_id.toString()), + invitation_id && slug + ? () => workspaceService.getWorkspaceInvitation(slug.toString(), invitation_id.toString()) + : null + ); + + const handleAccept = () => { + if (!invitationDetail) return; + workspaceService + .joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { + accepted: true, + token: token, + }) + .then(() => { + if (invitationDetail.email === currentUser?.email) { + router.push(`/${invitationDetail.workspace.slug}`); + } else { + router.push("/"); + } + }) + .catch((err: unknown) => console.error(err)); + }; + + const handleReject = () => { + if (!invitationDetail || !token) return; + void workspaceService + .joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { + accepted: false, + token: token, + }) + .then(() => { + router.push("/"); + }) + .catch((err: unknown) => console.error(err)); + }; + + return ( + +
+ {invitationDetail && !invitationDetail.responded_at ? ( + error ? ( +
+

INVITATION NOT FOUND

+
+ ) : ( + + + + + ) + ) : error || invitationDetail?.responded_at ? ( + invitationDetail?.accepted ? ( + + + + ) : ( + + {!currentUser ? ( + + ) : ( + + )} + + ) + ) : ( +
+ +
+ )} +
+
+ ); +} + +export default observer(WorkspaceInvitationPage); diff --git a/plane-src/apps/web/app/(home)/layout.tsx b/plane-src/apps/web/app/(home)/layout.tsx new file mode 100644 index 0000000..c723b07 --- /dev/null +++ b/plane-src/apps/web/app/(home)/layout.tsx @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +// types +import type { Route } from "./+types/layout"; + +export const meta: Route.MetaFunction = () => [ + { name: "robots", content: "index, nofollow" }, + { name: "viewport", content: "width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover" }, +]; + +export default function HomeLayout() { + return ; +} diff --git a/plane-src/apps/web/app/(home)/page.tsx b/plane-src/apps/web/app/(home)/page.tsx new file mode 100644 index 0000000..4717705 --- /dev/null +++ b/plane-src/apps/web/app/(home)/page.tsx @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +// components +import { AuthBase } from "@/components/auth-screens/auth-base"; +// helpers +import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; + +function HomePage() { + return ( + + + + + + ); +} + +export default HomePage; diff --git a/plane-src/apps/web/app/assets/404.svg b/plane-src/apps/web/app/assets/404.svg new file mode 100644 index 0000000..4c29841 --- /dev/null +++ b/plane-src/apps/web/app/assets/404.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/attachment/audio-icon.png b/plane-src/apps/web/app/assets/attachment/audio-icon.png new file mode 100644 index 0000000..a3e551e Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/audio-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/css-icon.png b/plane-src/apps/web/app/assets/attachment/css-icon.png new file mode 100644 index 0000000..cfb502d Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/css-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/csv-icon.png b/plane-src/apps/web/app/assets/attachment/csv-icon.png new file mode 100644 index 0000000..39d0ee7 Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/csv-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/default-icon.png b/plane-src/apps/web/app/assets/attachment/default-icon.png new file mode 100644 index 0000000..eb1ea51 Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/default-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/doc-icon.png b/plane-src/apps/web/app/assets/attachment/doc-icon.png new file mode 100644 index 0000000..d143372 Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/doc-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/excel-icon.png b/plane-src/apps/web/app/assets/attachment/excel-icon.png new file mode 100644 index 0000000..b3a1b85 Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/excel-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/figma-icon.png b/plane-src/apps/web/app/assets/attachment/figma-icon.png new file mode 100644 index 0000000..b4a1b63 Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/figma-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/html-icon.png b/plane-src/apps/web/app/assets/attachment/html-icon.png new file mode 100644 index 0000000..b6259a2 Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/html-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/img-icon.png b/plane-src/apps/web/app/assets/attachment/img-icon.png new file mode 100644 index 0000000..6c5b8fc Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/img-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/jpg-icon.png b/plane-src/apps/web/app/assets/attachment/jpg-icon.png new file mode 100644 index 0000000..dfd2c9f Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/jpg-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/js-icon.png b/plane-src/apps/web/app/assets/attachment/js-icon.png new file mode 100644 index 0000000..66aacda Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/js-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/pdf-icon.png b/plane-src/apps/web/app/assets/attachment/pdf-icon.png new file mode 100644 index 0000000..21c42d7 Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/pdf-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/png-icon.png b/plane-src/apps/web/app/assets/attachment/png-icon.png new file mode 100644 index 0000000..f04207d Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/png-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/rar-icon.png b/plane-src/apps/web/app/assets/attachment/rar-icon.png new file mode 100644 index 0000000..7305455 Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/rar-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/svg-icon.png b/plane-src/apps/web/app/assets/attachment/svg-icon.png new file mode 100644 index 0000000..856f94f Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/svg-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/txt-icon.png b/plane-src/apps/web/app/assets/attachment/txt-icon.png new file mode 100644 index 0000000..1c1babf Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/txt-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/video-icon.png b/plane-src/apps/web/app/assets/attachment/video-icon.png new file mode 100644 index 0000000..510838d Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/video-icon.png differ diff --git a/plane-src/apps/web/app/assets/attachment/zip-icon.png b/plane-src/apps/web/app/assets/attachment/zip-icon.png new file mode 100644 index 0000000..0db1da1 Binary files /dev/null and b/plane-src/apps/web/app/assets/attachment/zip-icon.png differ diff --git a/plane-src/apps/web/app/assets/auth/access-denied.svg b/plane-src/apps/web/app/assets/auth/access-denied.svg new file mode 100644 index 0000000..c7979fe --- /dev/null +++ b/plane-src/apps/web/app/assets/auth/access-denied.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/auth/background-pattern-dark.svg b/plane-src/apps/web/app/assets/auth/background-pattern-dark.svg new file mode 100644 index 0000000..c258cba --- /dev/null +++ b/plane-src/apps/web/app/assets/auth/background-pattern-dark.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/auth/background-pattern.svg b/plane-src/apps/web/app/assets/auth/background-pattern.svg new file mode 100644 index 0000000..5fcbeec --- /dev/null +++ b/plane-src/apps/web/app/assets/auth/background-pattern.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/auth/gradient-bg-logo.webp b/plane-src/apps/web/app/assets/auth/gradient-bg-logo.webp new file mode 100644 index 0000000..47202e5 Binary files /dev/null and b/plane-src/apps/web/app/assets/auth/gradient-bg-logo.webp differ diff --git a/plane-src/apps/web/app/assets/auth/gradient-logo.webp b/plane-src/apps/web/app/assets/auth/gradient-logo.webp new file mode 100644 index 0000000..674434c Binary files /dev/null and b/plane-src/apps/web/app/assets/auth/gradient-logo.webp differ diff --git a/plane-src/apps/web/app/assets/auth/project-not-authorized.svg b/plane-src/apps/web/app/assets/auth/project-not-authorized.svg new file mode 100644 index 0000000..515fd68 --- /dev/null +++ b/plane-src/apps/web/app/assets/auth/project-not-authorized.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/auth/unauthorized.svg b/plane-src/apps/web/app/assets/auth/unauthorized.svg new file mode 100644 index 0000000..2bad48a --- /dev/null +++ b/plane-src/apps/web/app/assets/auth/unauthorized.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Error unauthorized + \ No newline at end of file diff --git a/plane-src/apps/web/app/assets/auth/workspace-not-authorized.svg b/plane-src/apps/web/app/assets/auth/workspace-not-authorized.svg new file mode 100644 index 0000000..dad2b0c --- /dev/null +++ b/plane-src/apps/web/app/assets/auth/workspace-not-authorized.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/cover-images/image_1.jpg b/plane-src/apps/web/app/assets/cover-images/image_1.jpg new file mode 100644 index 0000000..3565e88 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_1.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_10.jpg b/plane-src/apps/web/app/assets/cover-images/image_10.jpg new file mode 100644 index 0000000..cecad95 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_10.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_11.jpg b/plane-src/apps/web/app/assets/cover-images/image_11.jpg new file mode 100644 index 0000000..ebe34f2 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_11.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_12.jpg b/plane-src/apps/web/app/assets/cover-images/image_12.jpg new file mode 100644 index 0000000..cae254d Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_12.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_13.jpg b/plane-src/apps/web/app/assets/cover-images/image_13.jpg new file mode 100644 index 0000000..a3f4d40 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_13.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_14.jpg b/plane-src/apps/web/app/assets/cover-images/image_14.jpg new file mode 100644 index 0000000..3b3da88 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_14.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_15.jpg b/plane-src/apps/web/app/assets/cover-images/image_15.jpg new file mode 100644 index 0000000..3e44b7f Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_15.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_16.jpg b/plane-src/apps/web/app/assets/cover-images/image_16.jpg new file mode 100644 index 0000000..f31335b Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_16.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_17.jpg b/plane-src/apps/web/app/assets/cover-images/image_17.jpg new file mode 100644 index 0000000..d2a5034 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_17.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_18.jpg b/plane-src/apps/web/app/assets/cover-images/image_18.jpg new file mode 100644 index 0000000..10cf37c Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_18.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_19.jpg b/plane-src/apps/web/app/assets/cover-images/image_19.jpg new file mode 100644 index 0000000..dca5619 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_19.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_2.jpg b/plane-src/apps/web/app/assets/cover-images/image_2.jpg new file mode 100644 index 0000000..a1adad5 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_2.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_20.jpg b/plane-src/apps/web/app/assets/cover-images/image_20.jpg new file mode 100644 index 0000000..a8daf97 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_20.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_21.jpg b/plane-src/apps/web/app/assets/cover-images/image_21.jpg new file mode 100644 index 0000000..57c094e Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_21.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_22.jpg b/plane-src/apps/web/app/assets/cover-images/image_22.jpg new file mode 100644 index 0000000..9efc564 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_22.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_23.jpg b/plane-src/apps/web/app/assets/cover-images/image_23.jpg new file mode 100644 index 0000000..fec33d9 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_23.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_24.jpg b/plane-src/apps/web/app/assets/cover-images/image_24.jpg new file mode 100644 index 0000000..54c74a6 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_24.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_25.jpg b/plane-src/apps/web/app/assets/cover-images/image_25.jpg new file mode 100644 index 0000000..66841c0 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_25.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_26.jpg b/plane-src/apps/web/app/assets/cover-images/image_26.jpg new file mode 100644 index 0000000..d0c2766 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_26.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_27.jpg b/plane-src/apps/web/app/assets/cover-images/image_27.jpg new file mode 100644 index 0000000..84abce2 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_27.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_28.jpg b/plane-src/apps/web/app/assets/cover-images/image_28.jpg new file mode 100644 index 0000000..0ce78e3 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_28.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_29.jpg b/plane-src/apps/web/app/assets/cover-images/image_29.jpg new file mode 100644 index 0000000..9df7aa0 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_29.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_3.jpg b/plane-src/apps/web/app/assets/cover-images/image_3.jpg new file mode 100644 index 0000000..4518492 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_3.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_4.jpg b/plane-src/apps/web/app/assets/cover-images/image_4.jpg new file mode 100644 index 0000000..04d1109 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_4.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_5.jpg b/plane-src/apps/web/app/assets/cover-images/image_5.jpg new file mode 100644 index 0000000..6dcdb25 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_5.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_6.jpg b/plane-src/apps/web/app/assets/cover-images/image_6.jpg new file mode 100644 index 0000000..f1cb9bf Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_6.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_7.jpg b/plane-src/apps/web/app/assets/cover-images/image_7.jpg new file mode 100644 index 0000000..c70602c Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_7.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_8.jpg b/plane-src/apps/web/app/assets/cover-images/image_8.jpg new file mode 100644 index 0000000..508bd55 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_8.jpg differ diff --git a/plane-src/apps/web/app/assets/cover-images/image_9.jpg b/plane-src/apps/web/app/assets/cover-images/image_9.jpg new file mode 100644 index 0000000..d5267f9 Binary files /dev/null and b/plane-src/apps/web/app/assets/cover-images/image_9.jpg differ diff --git a/plane-src/apps/web/app/assets/emoji/project-emoji.svg b/plane-src/apps/web/app/assets/emoji/project-emoji.svg new file mode 100644 index 0000000..a2b9a62 --- /dev/null +++ b/plane-src/apps/web/app/assets/emoji/project-emoji.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/active-cycle/assignee-dark.webp b/plane-src/apps/web/app/assets/empty-state/active-cycle/assignee-dark.webp new file mode 100644 index 0000000..2590627 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/active-cycle/assignee-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/active-cycle/assignee-light.webp b/plane-src/apps/web/app/assets/empty-state/active-cycle/assignee-light.webp new file mode 100644 index 0000000..b8ae5c0 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/active-cycle/assignee-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/active-cycle/chart-dark.webp b/plane-src/apps/web/app/assets/empty-state/active-cycle/chart-dark.webp new file mode 100644 index 0000000..033d6fe Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/active-cycle/chart-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/active-cycle/chart-light.webp b/plane-src/apps/web/app/assets/empty-state/active-cycle/chart-light.webp new file mode 100644 index 0000000..587906f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/active-cycle/chart-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/active-cycle/cycle-dark.webp b/plane-src/apps/web/app/assets/empty-state/active-cycle/cycle-dark.webp new file mode 100644 index 0000000..d092308 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/active-cycle/cycle-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/active-cycle/cycle-light.webp b/plane-src/apps/web/app/assets/empty-state/active-cycle/cycle-light.webp new file mode 100644 index 0000000..a218d4d Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/active-cycle/cycle-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/active-cycle/label-dark.webp b/plane-src/apps/web/app/assets/empty-state/active-cycle/label-dark.webp new file mode 100644 index 0000000..c15d3be Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/active-cycle/label-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/active-cycle/label-light.webp b/plane-src/apps/web/app/assets/empty-state/active-cycle/label-light.webp new file mode 100644 index 0000000..e5ccc69 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/active-cycle/label-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/active-cycle/priority-dark.webp b/plane-src/apps/web/app/assets/empty-state/active-cycle/priority-dark.webp new file mode 100644 index 0000000..f662e92 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/active-cycle/priority-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/active-cycle/priority-light.webp b/plane-src/apps/web/app/assets/empty-state/active-cycle/priority-light.webp new file mode 100644 index 0000000..fde814c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/active-cycle/priority-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/active-cycle/progress-dark.webp b/plane-src/apps/web/app/assets/empty-state/active-cycle/progress-dark.webp new file mode 100644 index 0000000..4beda66 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/active-cycle/progress-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/active-cycle/progress-light.webp b/plane-src/apps/web/app/assets/empty-state/active-cycle/progress-light.webp new file mode 100644 index 0000000..ae30cf7 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/active-cycle/progress-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/all-issues/all-issues-dark.webp b/plane-src/apps/web/app/assets/empty-state/all-issues/all-issues-dark.webp new file mode 100644 index 0000000..f570ad5 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/all-issues/all-issues-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/all-issues/all-issues-light.webp b/plane-src/apps/web/app/assets/empty-state/all-issues/all-issues-light.webp new file mode 100644 index 0000000..eba388d Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/all-issues/all-issues-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/all-issues/assigned-dark.webp b/plane-src/apps/web/app/assets/empty-state/all-issues/assigned-dark.webp new file mode 100644 index 0000000..23bc60a Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/all-issues/assigned-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/all-issues/assigned-light.webp b/plane-src/apps/web/app/assets/empty-state/all-issues/assigned-light.webp new file mode 100644 index 0000000..f48b9cf Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/all-issues/assigned-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/all-issues/created-dark.webp b/plane-src/apps/web/app/assets/empty-state/all-issues/created-dark.webp new file mode 100644 index 0000000..69d35fc Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/all-issues/created-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/all-issues/created-light.webp b/plane-src/apps/web/app/assets/empty-state/all-issues/created-light.webp new file mode 100644 index 0000000..be4f67d Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/all-issues/created-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/all-issues/custom-view-dark.webp b/plane-src/apps/web/app/assets/empty-state/all-issues/custom-view-dark.webp new file mode 100644 index 0000000..48dec16 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/all-issues/custom-view-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/all-issues/custom-view-light.webp b/plane-src/apps/web/app/assets/empty-state/all-issues/custom-view-light.webp new file mode 100644 index 0000000..53b551c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/all-issues/custom-view-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/all-issues/no-project-dark.webp b/plane-src/apps/web/app/assets/empty-state/all-issues/no-project-dark.webp new file mode 100644 index 0000000..641ec82 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/all-issues/no-project-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/all-issues/no-project-light.webp b/plane-src/apps/web/app/assets/empty-state/all-issues/no-project-light.webp new file mode 100644 index 0000000..1c7a810 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/all-issues/no-project-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/all-issues/subscribed-dark.webp b/plane-src/apps/web/app/assets/empty-state/all-issues/subscribed-dark.webp new file mode 100644 index 0000000..68b3b30 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/all-issues/subscribed-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/all-issues/subscribed-light.webp b/plane-src/apps/web/app/assets/empty-state/all-issues/subscribed-light.webp new file mode 100644 index 0000000..4dbbedc Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/all-issues/subscribed-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-area-dark.webp b/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-area-dark.webp new file mode 100644 index 0000000..509f66c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-area-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-area-light.webp b/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-area-light.webp new file mode 100644 index 0000000..cfa27c8 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-area-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-bar-dark.webp b/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-bar-dark.webp new file mode 100644 index 0000000..e519574 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-bar-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-bar-light.webp b/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-bar-light.webp new file mode 100644 index 0000000..e6eeb09 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-bar-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-radar-dark.webp b/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-radar-dark.webp new file mode 100644 index 0000000..94e2f51 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-radar-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-radar-light.webp b/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-radar-light.webp new file mode 100644 index 0000000..2982016 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/analytics/empty-chart-radar-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/analytics/empty-grid-background-dark.webp b/plane-src/apps/web/app/assets/empty-state/analytics/empty-grid-background-dark.webp new file mode 100644 index 0000000..5e420f2 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/analytics/empty-grid-background-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/analytics/empty-grid-background-light.webp b/plane-src/apps/web/app/assets/empty-state/analytics/empty-grid-background-light.webp new file mode 100644 index 0000000..592a128 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/analytics/empty-grid-background-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/analytics/empty-table-dark.webp b/plane-src/apps/web/app/assets/empty-state/analytics/empty-table-dark.webp new file mode 100644 index 0000000..f5fcedd Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/analytics/empty-table-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/analytics/empty-table-light.webp b/plane-src/apps/web/app/assets/empty-state/analytics/empty-table-light.webp new file mode 100644 index 0000000..c9e8fec Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/analytics/empty-table-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/api-token.svg b/plane-src/apps/web/app/assets/empty-state/api-token.svg new file mode 100644 index 0000000..f8e32d3 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/api-token.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/archived/empty-cycles-dark.webp b/plane-src/apps/web/app/assets/empty-state/archived/empty-cycles-dark.webp new file mode 100644 index 0000000..872fb2f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/archived/empty-cycles-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/archived/empty-cycles-light.webp b/plane-src/apps/web/app/assets/empty-state/archived/empty-cycles-light.webp new file mode 100644 index 0000000..2db1dc4 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/archived/empty-cycles-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/archived/empty-issues-dark.webp b/plane-src/apps/web/app/assets/empty-state/archived/empty-issues-dark.webp new file mode 100644 index 0000000..c18e946 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/archived/empty-issues-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/archived/empty-issues-light.webp b/plane-src/apps/web/app/assets/empty-state/archived/empty-issues-light.webp new file mode 100644 index 0000000..d99a501 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/archived/empty-issues-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/archived/empty-modules-dark.webp b/plane-src/apps/web/app/assets/empty-state/archived/empty-modules-dark.webp new file mode 100644 index 0000000..e34bf37 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/archived/empty-modules-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/archived/empty-modules-light.webp b/plane-src/apps/web/app/assets/empty-state/archived/empty-modules-light.webp new file mode 100644 index 0000000..5caf2ea Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/archived/empty-modules-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/calendar-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/calendar-dark-resp.webp new file mode 100644 index 0000000..5f0c219 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/calendar-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/calendar-dark.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/calendar-dark.webp new file mode 100644 index 0000000..312b138 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/calendar-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/calendar-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/calendar-light-resp.webp new file mode 100644 index 0000000..556a450 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/calendar-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/calendar-light.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/calendar-light.webp new file mode 100644 index 0000000..aef2ff1 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/calendar-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark-resp.webp new file mode 100644 index 0000000..e2eda05 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark.webp new file mode 100644 index 0000000..d2a9dfb Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light-resp.webp new file mode 100644 index 0000000..1a915d3 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light.webp new file mode 100644 index 0000000..9dcbdd6 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/kanban-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/kanban-dark-resp.webp new file mode 100644 index 0000000..227dd48 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/kanban-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/kanban-dark.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/kanban-dark.webp new file mode 100644 index 0000000..b161dbc Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/kanban-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/kanban-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/kanban-light-resp.webp new file mode 100644 index 0000000..c377068 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/kanban-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/kanban-light.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/kanban-light.webp new file mode 100644 index 0000000..0958f21 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/kanban-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/list-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/list-dark-resp.webp new file mode 100644 index 0000000..4088595 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/list-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/list-dark.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/list-dark.webp new file mode 100644 index 0000000..7e9f8c6 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/list-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/list-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/list-light-resp.webp new file mode 100644 index 0000000..862544a Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/list-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/list-light.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/list-light.webp new file mode 100644 index 0000000..fb01bff Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/list-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark-resp.webp new file mode 100644 index 0000000..baccb3f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark.webp new file mode 100644 index 0000000..6f60361 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light-resp.webp new file mode 100644 index 0000000..c7889d6 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light.webp b/plane-src/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light.webp new file mode 100644 index 0000000..0f35b4e Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle.svg b/plane-src/apps/web/app/assets/empty-state/cycle.svg new file mode 100644 index 0000000..fe1dba3 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/cycle.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/cycle/active-dark.webp b/plane-src/apps/web/app/assets/empty-state/cycle/active-dark.webp new file mode 100644 index 0000000..68317b1 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle/active-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle/active-light.webp b/plane-src/apps/web/app/assets/empty-state/cycle/active-light.webp new file mode 100644 index 0000000..2341a90 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle/active-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle/all-filters.svg b/plane-src/apps/web/app/assets/empty-state/cycle/all-filters.svg new file mode 100644 index 0000000..b00841c --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/cycle/all-filters.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/cycle/completed-dark.webp b/plane-src/apps/web/app/assets/empty-state/cycle/completed-dark.webp new file mode 100644 index 0000000..eebe656 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle/completed-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle/completed-light.webp b/plane-src/apps/web/app/assets/empty-state/cycle/completed-light.webp new file mode 100644 index 0000000..6c192fd Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle/completed-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle/completed-no-issues-dark.webp b/plane-src/apps/web/app/assets/empty-state/cycle/completed-no-issues-dark.webp new file mode 100644 index 0000000..a187dbf Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle/completed-no-issues-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle/completed-no-issues-light.webp b/plane-src/apps/web/app/assets/empty-state/cycle/completed-no-issues-light.webp new file mode 100644 index 0000000..6009f60 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle/completed-no-issues-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle/draft-dark.webp b/plane-src/apps/web/app/assets/empty-state/cycle/draft-dark.webp new file mode 100644 index 0000000..887e2e9 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle/draft-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle/draft-light.webp b/plane-src/apps/web/app/assets/empty-state/cycle/draft-light.webp new file mode 100644 index 0000000..d1c88bc Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle/draft-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle/name-filter.svg b/plane-src/apps/web/app/assets/empty-state/cycle/name-filter.svg new file mode 100644 index 0000000..1686111 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/cycle/name-filter.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/cycle/upcoming-dark.webp b/plane-src/apps/web/app/assets/empty-state/cycle/upcoming-dark.webp new file mode 100644 index 0000000..205164f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle/upcoming-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/cycle/upcoming-light.webp b/plane-src/apps/web/app/assets/empty-state/cycle/upcoming-light.webp new file mode 100644 index 0000000..522122f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/cycle/upcoming-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/dark/completed-issues.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/completed-issues.svg new file mode 100644 index 0000000..8c4d083 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/completed-issues.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/dark/issues-by-priority.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/issues-by-priority.svg new file mode 100644 index 0000000..803f3a7 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/issues-by-priority.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/dark/issues-by-state-group.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/issues-by-state-group.svg new file mode 100644 index 0000000..b615c31 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/issues-by-state-group.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/dark/overdue-issues.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/overdue-issues.svg new file mode 100644 index 0000000..cceb348 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/overdue-issues.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/dark/recent-activity.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/recent-activity.svg new file mode 100644 index 0000000..bf72348 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/recent-activity.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-1.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-1.svg new file mode 100644 index 0000000..341364f --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-2.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-2.svg new file mode 100644 index 0000000..6889f75 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-3.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-3.svg new file mode 100644 index 0000000..deb0e21 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-3.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/dark/upcoming-issues.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/upcoming-issues.svg new file mode 100644 index 0000000..2559945 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/dark/upcoming-issues.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/light/completed-issues.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/light/completed-issues.svg new file mode 100644 index 0000000..6f9c4c4 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/light/completed-issues.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/light/issues-by-priority.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/light/issues-by-priority.svg new file mode 100644 index 0000000..23c35d9 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/light/issues-by-priority.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/light/issues-by-state-group.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/light/issues-by-state-group.svg new file mode 100644 index 0000000..eb32753 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/light/issues-by-state-group.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/light/overdue-issues.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/light/overdue-issues.svg new file mode 100644 index 0000000..1c66ddd --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/light/overdue-issues.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/light/recent-activity.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/light/recent-activity.svg new file mode 100644 index 0000000..3bb6749 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/light/recent-activity.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-1.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-1.svg new file mode 100644 index 0000000..fb77e41 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-2.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-2.svg new file mode 100644 index 0000000..e25fc95 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-3.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-3.svg new file mode 100644 index 0000000..e46473e --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-3.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/light/upcoming-issues.svg b/plane-src/apps/web/app/assets/empty-state/dashboard/light/upcoming-issues.svg new file mode 100644 index 0000000..ad30358 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/dashboard/light/upcoming-issues.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/widgets-dark.webp b/plane-src/apps/web/app/assets/empty-state/dashboard/widgets-dark.webp new file mode 100644 index 0000000..3a626f7 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/dashboard/widgets-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard/widgets-light.webp b/plane-src/apps/web/app/assets/empty-state/dashboard/widgets-light.webp new file mode 100644 index 0000000..6161c57 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/dashboard/widgets-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/dashboard_empty_project.webp b/plane-src/apps/web/app/assets/empty-state/dashboard_empty_project.webp new file mode 100644 index 0000000..c1f633f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/dashboard_empty_project.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/disabled-feature/cycles-dark.webp b/plane-src/apps/web/app/assets/empty-state/disabled-feature/cycles-dark.webp new file mode 100644 index 0000000..5897062 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/disabled-feature/cycles-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/disabled-feature/cycles-light.webp b/plane-src/apps/web/app/assets/empty-state/disabled-feature/cycles-light.webp new file mode 100644 index 0000000..c13d082 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/disabled-feature/cycles-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/disabled-feature/intake-dark.webp b/plane-src/apps/web/app/assets/empty-state/disabled-feature/intake-dark.webp new file mode 100644 index 0000000..9b459b8 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/disabled-feature/intake-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/disabled-feature/intake-light.webp b/plane-src/apps/web/app/assets/empty-state/disabled-feature/intake-light.webp new file mode 100644 index 0000000..70757be Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/disabled-feature/intake-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/disabled-feature/modules-dark.webp b/plane-src/apps/web/app/assets/empty-state/disabled-feature/modules-dark.webp new file mode 100644 index 0000000..8e198ed Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/disabled-feature/modules-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/disabled-feature/modules-light.webp b/plane-src/apps/web/app/assets/empty-state/disabled-feature/modules-light.webp new file mode 100644 index 0000000..d1db65c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/disabled-feature/modules-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/disabled-feature/pages-dark.webp b/plane-src/apps/web/app/assets/empty-state/disabled-feature/pages-dark.webp new file mode 100644 index 0000000..19263a0 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/disabled-feature/pages-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/disabled-feature/pages-light.webp b/plane-src/apps/web/app/assets/empty-state/disabled-feature/pages-light.webp new file mode 100644 index 0000000..fd1c5b4 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/disabled-feature/pages-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/disabled-feature/views-dark.webp b/plane-src/apps/web/app/assets/empty-state/disabled-feature/views-dark.webp new file mode 100644 index 0000000..bfdb573 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/disabled-feature/views-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/disabled-feature/views-light.webp b/plane-src/apps/web/app/assets/empty-state/disabled-feature/views-light.webp new file mode 100644 index 0000000..e318e27 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/disabled-feature/views-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/draft/draft-issues-empty-dark.webp b/plane-src/apps/web/app/assets/empty-state/draft/draft-issues-empty-dark.webp new file mode 100644 index 0000000..c50ed24 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/draft/draft-issues-empty-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/draft/draft-issues-empty-light.webp b/plane-src/apps/web/app/assets/empty-state/draft/draft-issues-empty-light.webp new file mode 100644 index 0000000..c6ded2b Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/draft/draft-issues-empty-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty-filters/calendar-dark.webp b/plane-src/apps/web/app/assets/empty-state/empty-filters/calendar-dark.webp new file mode 100644 index 0000000..872be08 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty-filters/calendar-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty-filters/calendar-light.webp b/plane-src/apps/web/app/assets/empty-state/empty-filters/calendar-light.webp new file mode 100644 index 0000000..50e56eb Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty-filters/calendar-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty-filters/gantt_chart-dark.webp b/plane-src/apps/web/app/assets/empty-state/empty-filters/gantt_chart-dark.webp new file mode 100644 index 0000000..c587d96 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty-filters/gantt_chart-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty-filters/gantt_chart-light.webp b/plane-src/apps/web/app/assets/empty-state/empty-filters/gantt_chart-light.webp new file mode 100644 index 0000000..8c874ea Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty-filters/gantt_chart-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty-filters/kanban-dark.webp b/plane-src/apps/web/app/assets/empty-state/empty-filters/kanban-dark.webp new file mode 100644 index 0000000..a368c68 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty-filters/kanban-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty-filters/kanban-light.webp b/plane-src/apps/web/app/assets/empty-state/empty-filters/kanban-light.webp new file mode 100644 index 0000000..50507e7 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty-filters/kanban-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty-filters/list-dark.webp b/plane-src/apps/web/app/assets/empty-state/empty-filters/list-dark.webp new file mode 100644 index 0000000..6c49f29 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty-filters/list-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty-filters/list-light.webp b/plane-src/apps/web/app/assets/empty-state/empty-filters/list-light.webp new file mode 100644 index 0000000..bc4f284 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty-filters/list-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty-filters/spreadsheet-dark.webp b/plane-src/apps/web/app/assets/empty-state/empty-filters/spreadsheet-dark.webp new file mode 100644 index 0000000..2e9ff4c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty-filters/spreadsheet-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty-filters/spreadsheet-light.webp b/plane-src/apps/web/app/assets/empty-state/empty-filters/spreadsheet-light.webp new file mode 100644 index 0000000..174b3d0 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty-filters/spreadsheet-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty-updates-light.png b/plane-src/apps/web/app/assets/empty-state/empty-updates-light.png new file mode 100644 index 0000000..a92c093 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty-updates-light.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty_analytics.webp b/plane-src/apps/web/app/assets/empty-state/empty_analytics.webp new file mode 100644 index 0000000..46174ff Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty_analytics.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty_bar_graph.svg b/plane-src/apps/web/app/assets/empty-state/empty_bar_graph.svg new file mode 100644 index 0000000..7742a42 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/empty_bar_graph.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/empty_cycles.webp b/plane-src/apps/web/app/assets/empty-state/empty_cycles.webp new file mode 100644 index 0000000..710ae2d Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty_cycles.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty_graph.svg b/plane-src/apps/web/app/assets/empty-state/empty_graph.svg new file mode 100644 index 0000000..5eae523 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/empty_graph.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/empty_issues.webp b/plane-src/apps/web/app/assets/empty-state/empty_issues.webp new file mode 100644 index 0000000..e60d663 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty_issues.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty_label.svg b/plane-src/apps/web/app/assets/empty-state/empty_label.svg new file mode 100644 index 0000000..c664da6 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/empty_label.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/empty_members.svg b/plane-src/apps/web/app/assets/empty-state/empty_members.svg new file mode 100644 index 0000000..6672c58 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/empty_members.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/empty_modules.webp b/plane-src/apps/web/app/assets/empty-state/empty_modules.webp new file mode 100644 index 0000000..49050b5 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty_modules.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty_page.png b/plane-src/apps/web/app/assets/empty-state/empty_page.png new file mode 100644 index 0000000..993774c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty_page.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty_project.webp b/plane-src/apps/web/app/assets/empty-state/empty_project.webp new file mode 100644 index 0000000..ad66f09 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty_project.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/empty_users.svg b/plane-src/apps/web/app/assets/empty-state/empty_users.svg new file mode 100644 index 0000000..7298e9e --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/empty_users.svg @@ -0,0 +1,3 @@ + + + diff --git a/plane-src/apps/web/app/assets/empty-state/empty_view.webp b/plane-src/apps/web/app/assets/empty-state/empty_view.webp new file mode 100644 index 0000000..aa0f5a6 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/empty_view.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/epics/epics-dark.webp b/plane-src/apps/web/app/assets/empty-state/epics/epics-dark.webp new file mode 100644 index 0000000..648392a Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/epics/epics-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/epics/epics-light.webp b/plane-src/apps/web/app/assets/empty-state/epics/epics-light.webp new file mode 100644 index 0000000..f677e5d Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/epics/epics-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/epics/settings-dark.webp b/plane-src/apps/web/app/assets/empty-state/epics/settings-dark.webp new file mode 100644 index 0000000..3193137 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/epics/settings-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/epics/settings-light.webp b/plane-src/apps/web/app/assets/empty-state/epics/settings-light.webp new file mode 100644 index 0000000..1f9596e Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/epics/settings-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/estimates/dark.svg b/plane-src/apps/web/app/assets/empty-state/estimates/dark.svg new file mode 100644 index 0000000..2f5d758 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/estimates/dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/estimates/light.svg b/plane-src/apps/web/app/assets/empty-state/estimates/light.svg new file mode 100644 index 0000000..817b9fb --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/estimates/light.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/intake/filter-issue-dark.webp b/plane-src/apps/web/app/assets/empty-state/intake/filter-issue-dark.webp new file mode 100644 index 0000000..dab3eb7 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/intake/filter-issue-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/intake/filter-issue-light.webp b/plane-src/apps/web/app/assets/empty-state/intake/filter-issue-light.webp new file mode 100644 index 0000000..6716706 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/intake/filter-issue-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/intake/intake-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/intake/intake-dark-resp.webp new file mode 100644 index 0000000..bf6166e Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/intake/intake-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/intake/intake-dark.webp b/plane-src/apps/web/app/assets/empty-state/intake/intake-dark.webp new file mode 100644 index 0000000..9b459b8 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/intake/intake-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/intake/intake-issue-dark.webp b/plane-src/apps/web/app/assets/empty-state/intake/intake-issue-dark.webp new file mode 100644 index 0000000..0c14ce2 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/intake/intake-issue-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/intake/intake-issue-light.webp b/plane-src/apps/web/app/assets/empty-state/intake/intake-issue-light.webp new file mode 100644 index 0000000..c557f5d Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/intake/intake-issue-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/intake/intake-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/intake/intake-light-resp.webp new file mode 100644 index 0000000..69a79d6 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/intake/intake-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/intake/intake-light.webp b/plane-src/apps/web/app/assets/empty-state/intake/intake-light.webp new file mode 100644 index 0000000..70757be Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/intake/intake-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/intake/issue-detail-dark.webp b/plane-src/apps/web/app/assets/empty-state/intake/issue-detail-dark.webp new file mode 100644 index 0000000..2a2c706 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/intake/issue-detail-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/intake/issue-detail-light.webp b/plane-src/apps/web/app/assets/empty-state/intake/issue-detail-light.webp new file mode 100644 index 0000000..a713069 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/intake/issue-detail-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/invitation.svg b/plane-src/apps/web/app/assets/empty-state/invitation.svg new file mode 100644 index 0000000..bda66da --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/invitation.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/issue.svg b/plane-src/apps/web/app/assets/empty-state/issue.svg new file mode 100644 index 0000000..98a420d --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/issue.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/label.svg b/plane-src/apps/web/app/assets/empty-state/label.svg new file mode 100644 index 0000000..55241e2 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/label.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/calendar-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/calendar-dark-resp.webp new file mode 100644 index 0000000..5e6404e Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/calendar-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/calendar-dark.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/calendar-dark.webp new file mode 100644 index 0000000..5170f6c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/calendar-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/calendar-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/calendar-light-resp.webp new file mode 100644 index 0000000..0d890af Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/calendar-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/calendar-light.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/calendar-light.webp new file mode 100644 index 0000000..5bee9c9 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/calendar-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/gantt_chart-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/gantt_chart-dark-resp.webp new file mode 100644 index 0000000..90d7985 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/gantt_chart-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/gantt_chart-dark.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/gantt_chart-dark.webp new file mode 100644 index 0000000..d382198 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/gantt_chart-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/gantt_chart-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/gantt_chart-light-resp.webp new file mode 100644 index 0000000..ccaadee Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/gantt_chart-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/gantt_chart-light.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/gantt_chart-light.webp new file mode 100644 index 0000000..e51bbee Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/gantt_chart-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/kanban-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/kanban-dark-resp.webp new file mode 100644 index 0000000..b447fb6 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/kanban-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/kanban-dark.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/kanban-dark.webp new file mode 100644 index 0000000..5e978d5 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/kanban-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/kanban-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/kanban-light-resp.webp new file mode 100644 index 0000000..2be0b8f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/kanban-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/kanban-light.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/kanban-light.webp new file mode 100644 index 0000000..5d30a65 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/kanban-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/list-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/list-dark-resp.webp new file mode 100644 index 0000000..84d92aa Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/list-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/list-dark.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/list-dark.webp new file mode 100644 index 0000000..96e9518 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/list-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/list-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/list-light-resp.webp new file mode 100644 index 0000000..b688add Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/list-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/list-light.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/list-light.webp new file mode 100644 index 0000000..d9b1c4c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/list-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark-resp.webp new file mode 100644 index 0000000..afb2f1c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark.webp new file mode 100644 index 0000000..1894cfe Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/spreadsheet-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/spreadsheet-light-resp.webp new file mode 100644 index 0000000..11115d4 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/spreadsheet-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module-issues/spreadsheet-light.webp b/plane-src/apps/web/app/assets/empty-state/module-issues/spreadsheet-light.webp new file mode 100644 index 0000000..aa3813f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/module-issues/spreadsheet-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/module.svg b/plane-src/apps/web/app/assets/empty-state/module.svg new file mode 100644 index 0000000..359b3e9 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/module.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/module/all-filters.svg b/plane-src/apps/web/app/assets/empty-state/module/all-filters.svg new file mode 100644 index 0000000..6ba0731 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/module/all-filters.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/module/name-filter.svg b/plane-src/apps/web/app/assets/empty-state/module/name-filter.svg new file mode 100644 index 0000000..0d9655b --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/module/name-filter.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/notification.svg b/plane-src/apps/web/app/assets/empty-state/notification.svg new file mode 100644 index 0000000..700a155 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/notification.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/analytics-dark.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/analytics-dark.webp new file mode 100644 index 0000000..a83d55d Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/analytics-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/analytics-light.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/analytics-light.webp new file mode 100644 index 0000000..64a762f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/analytics-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/archive-dark.png b/plane-src/apps/web/app/assets/empty-state/onboarding/archive-dark.png new file mode 100644 index 0000000..54cca36 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/archive-dark.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/archive-light.png b/plane-src/apps/web/app/assets/empty-state/onboarding/archive-light.png new file mode 100644 index 0000000..8530bba Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/archive-light.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/cycles-dark.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/cycles-dark.webp new file mode 100644 index 0000000..716f121 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/cycles-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/cycles-light.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/cycles-light.webp new file mode 100644 index 0000000..0b2b400 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/cycles-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/dashboard-dark.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/dashboard-dark.webp new file mode 100644 index 0000000..afa10be Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/dashboard-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/dashboard-light.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/dashboard-light.webp new file mode 100644 index 0000000..d4bce8b Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/dashboard-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/graph-dark.png b/plane-src/apps/web/app/assets/empty-state/onboarding/graph-dark.png new file mode 100644 index 0000000..e8a938d Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/graph-dark.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/graph-light.png b/plane-src/apps/web/app/assets/empty-state/onboarding/graph-light.png new file mode 100644 index 0000000..eb48b45 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/graph-light.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/issues-closed-dark.png b/plane-src/apps/web/app/assets/empty-state/onboarding/issues-closed-dark.png new file mode 100644 index 0000000..d4bafb2 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/issues-closed-dark.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/issues-closed-light.png b/plane-src/apps/web/app/assets/empty-state/onboarding/issues-closed-light.png new file mode 100644 index 0000000..739acd1 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/issues-closed-light.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/issues-dark.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/issues-dark.webp new file mode 100644 index 0000000..39ffb5c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/issues-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/issues-light.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/issues-light.webp new file mode 100644 index 0000000..18d42b1 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/issues-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/members-dark.png b/plane-src/apps/web/app/assets/empty-state/onboarding/members-dark.png new file mode 100644 index 0000000..2f8a1c8 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/members-dark.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/members-light.png b/plane-src/apps/web/app/assets/empty-state/onboarding/members-light.png new file mode 100644 index 0000000..eaa43ef Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/members-light.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/modules-dark.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/modules-dark.webp new file mode 100644 index 0000000..d855df0 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/modules-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/modules-light.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/modules-light.webp new file mode 100644 index 0000000..ff3e6ac Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/modules-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/notification-dark.png b/plane-src/apps/web/app/assets/empty-state/onboarding/notification-dark.png new file mode 100644 index 0000000..dee284c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/notification-dark.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/notification-light.png b/plane-src/apps/web/app/assets/empty-state/onboarding/notification-light.png new file mode 100644 index 0000000..3065787 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/notification-light.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/pages-dark.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/pages-dark.webp new file mode 100644 index 0000000..bc59270 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/pages-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/pages-light.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/pages-light.webp new file mode 100644 index 0000000..1e28c0d Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/pages-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/projects-dark.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/projects-dark.webp new file mode 100644 index 0000000..d4f6c62 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/projects-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/projects-light.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/projects-light.webp new file mode 100644 index 0000000..c475c8f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/projects-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/search-dark.png b/plane-src/apps/web/app/assets/empty-state/onboarding/search-dark.png new file mode 100644 index 0000000..531081d Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/search-dark.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/search-light.png b/plane-src/apps/web/app/assets/empty-state/onboarding/search-light.png new file mode 100644 index 0000000..39e2636 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/search-light.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/snooze-light.png b/plane-src/apps/web/app/assets/empty-state/onboarding/snooze-light.png new file mode 100644 index 0000000..6ccd7ab Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/snooze-light.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/snoozed-dark.png b/plane-src/apps/web/app/assets/empty-state/onboarding/snoozed-dark.png new file mode 100644 index 0000000..c8b88fe Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/snoozed-dark.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/views-dark.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/views-dark.webp new file mode 100644 index 0000000..0a7fe7b Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/views-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/views-light.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/views-light.webp new file mode 100644 index 0000000..cfb2031 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/views-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-dark.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-dark.webp new file mode 100644 index 0000000..68317b1 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-light.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-light.webp new file mode 100644 index 0000000..2341a90 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/workspace-invites-dark.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/workspace-invites-dark.webp new file mode 100644 index 0000000..d6e1004 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/workspace-invites-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/onboarding/workspace-invites-light.webp b/plane-src/apps/web/app/assets/empty-state/onboarding/workspace-invites-light.webp new file mode 100644 index 0000000..123a600 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/onboarding/workspace-invites-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/activities-dark.webp b/plane-src/apps/web/app/assets/empty-state/profile/activities-dark.webp new file mode 100644 index 0000000..1b69394 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/activities-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/activities-light.webp b/plane-src/apps/web/app/assets/empty-state/profile/activities-light.webp new file mode 100644 index 0000000..c287e0c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/activities-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/activity-dark.webp b/plane-src/apps/web/app/assets/empty-state/profile/activity-dark.webp new file mode 100644 index 0000000..d44e6a1 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/activity-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/activity-light.webp b/plane-src/apps/web/app/assets/empty-state/profile/activity-light.webp new file mode 100644 index 0000000..206685a Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/activity-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/assigned-dark.webp b/plane-src/apps/web/app/assets/empty-state/profile/assigned-dark.webp new file mode 100644 index 0000000..173ebe6 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/assigned-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/assigned-light.webp b/plane-src/apps/web/app/assets/empty-state/profile/assigned-light.webp new file mode 100644 index 0000000..78416c9 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/assigned-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/created-dark.webp b/plane-src/apps/web/app/assets/empty-state/profile/created-dark.webp new file mode 100644 index 0000000..8232ce2 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/created-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/created-light.webp b/plane-src/apps/web/app/assets/empty-state/profile/created-light.webp new file mode 100644 index 0000000..4ea037a Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/created-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/issues-by-priority-dark.webp b/plane-src/apps/web/app/assets/empty-state/profile/issues-by-priority-dark.webp new file mode 100644 index 0000000..e1a7180 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/issues-by-priority-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/issues-by-priority-light.webp b/plane-src/apps/web/app/assets/empty-state/profile/issues-by-priority-light.webp new file mode 100644 index 0000000..16abada Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/issues-by-priority-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/issues-by-state-dark.webp b/plane-src/apps/web/app/assets/empty-state/profile/issues-by-state-dark.webp new file mode 100644 index 0000000..82210aa Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/issues-by-state-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/issues-by-state-light.webp b/plane-src/apps/web/app/assets/empty-state/profile/issues-by-state-light.webp new file mode 100644 index 0000000..73788bb Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/issues-by-state-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/subscribed-dark.webp b/plane-src/apps/web/app/assets/empty-state/profile/subscribed-dark.webp new file mode 100644 index 0000000..26837e1 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/subscribed-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/profile/subscribed-light.webp b/plane-src/apps/web/app/assets/empty-state/profile/subscribed-light.webp new file mode 100644 index 0000000..7faf660 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/profile/subscribed-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-dark-resp.webp new file mode 100644 index 0000000..6f6a494 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-dark.png b/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-dark.png new file mode 100644 index 0000000..9cc5346 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-dark.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-dark.webp b/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-dark.webp new file mode 100644 index 0000000..35d42e5 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-light-resp.webp new file mode 100644 index 0000000..13a29ca Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-light.png b/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-light.png new file mode 100644 index 0000000..f846a2a Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-light.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-light.webp b/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-light.webp new file mode 100644 index 0000000..b329ea9 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/estimates-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/integrations-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/project-settings/integrations-dark-resp.webp new file mode 100644 index 0000000..f968f8f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/integrations-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/integrations-dark.webp b/plane-src/apps/web/app/assets/empty-state/project-settings/integrations-dark.webp new file mode 100644 index 0000000..f968f8f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/integrations-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/integrations-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/project-settings/integrations-light-resp.webp new file mode 100644 index 0000000..94f6ffe Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/integrations-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/integrations-light.webp b/plane-src/apps/web/app/assets/empty-state/project-settings/integrations-light.webp new file mode 100644 index 0000000..94f6ffe Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/integrations-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/labels-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/project-settings/labels-dark-resp.webp new file mode 100644 index 0000000..bf23c60 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/labels-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/labels-dark.webp b/plane-src/apps/web/app/assets/empty-state/project-settings/labels-dark.webp new file mode 100644 index 0000000..6d41f55 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/labels-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/labels-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/project-settings/labels-light-resp.webp new file mode 100644 index 0000000..d992fb1 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/labels-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/labels-light.webp b/plane-src/apps/web/app/assets/empty-state/project-settings/labels-light.webp new file mode 100644 index 0000000..c6e7015 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/labels-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/no-projects-dark.png b/plane-src/apps/web/app/assets/empty-state/project-settings/no-projects-dark.png new file mode 100644 index 0000000..deed3d0 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/no-projects-dark.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/no-projects-light.png b/plane-src/apps/web/app/assets/empty-state/project-settings/no-projects-light.png new file mode 100644 index 0000000..e038de2 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/no-projects-light.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/updates-dark.png b/plane-src/apps/web/app/assets/empty-state/project-settings/updates-dark.png new file mode 100644 index 0000000..46e2709 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/updates-dark.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/project-settings/updates-light.png b/plane-src/apps/web/app/assets/empty-state/project-settings/updates-light.png new file mode 100644 index 0000000..d2e9208 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/project-settings/updates-light.png differ diff --git a/plane-src/apps/web/app/assets/empty-state/project.svg b/plane-src/apps/web/app/assets/empty-state/project.svg new file mode 100644 index 0000000..b898549 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/project.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/project/all-filters-dark.svg b/plane-src/apps/web/app/assets/empty-state/project/all-filters-dark.svg new file mode 100644 index 0000000..e0d69c4 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/project/all-filters-dark.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/project/all-filters-light.svg b/plane-src/apps/web/app/assets/empty-state/project/all-filters-light.svg new file mode 100644 index 0000000..93ac4de --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/project/all-filters-light.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/project/name-filter-dark.svg b/plane-src/apps/web/app/assets/empty-state/project/name-filter-dark.svg new file mode 100644 index 0000000..df4c237 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/project/name-filter-dark.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/project/name-filter-light.svg b/plane-src/apps/web/app/assets/empty-state/project/name-filter-light.svg new file mode 100644 index 0000000..ec63228 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/project/name-filter-light.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/project/name-filter.svg b/plane-src/apps/web/app/assets/empty-state/project/name-filter.svg new file mode 100644 index 0000000..ec63228 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/project/name-filter.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/recent_activity.svg b/plane-src/apps/web/app/assets/empty-state/recent_activity.svg new file mode 100644 index 0000000..bef318f --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/recent_activity.svg @@ -0,0 +1,3 @@ + + + diff --git a/plane-src/apps/web/app/assets/empty-state/search/all-issue-view-dark.webp b/plane-src/apps/web/app/assets/empty-state/search/all-issue-view-dark.webp new file mode 100644 index 0000000..dbc69c0 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/all-issue-view-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/all-issues-view-light.webp b/plane-src/apps/web/app/assets/empty-state/search/all-issues-view-light.webp new file mode 100644 index 0000000..829b973 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/all-issues-view-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/archive-dark.webp b/plane-src/apps/web/app/assets/empty-state/search/archive-dark.webp new file mode 100644 index 0000000..d586be8 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/archive-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/archive-light.webp b/plane-src/apps/web/app/assets/empty-state/search/archive-light.webp new file mode 100644 index 0000000..5a79179 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/archive-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/comments-dark.webp b/plane-src/apps/web/app/assets/empty-state/search/comments-dark.webp new file mode 100644 index 0000000..d06ba7e Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/comments-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/comments-light.webp b/plane-src/apps/web/app/assets/empty-state/search/comments-light.webp new file mode 100644 index 0000000..5a66067 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/comments-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/issues-dark.webp b/plane-src/apps/web/app/assets/empty-state/search/issues-dark.webp new file mode 100644 index 0000000..dcd00aa Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/issues-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/issues-light.webp b/plane-src/apps/web/app/assets/empty-state/search/issues-light.webp new file mode 100644 index 0000000..7703fde Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/issues-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/member-dark.webp b/plane-src/apps/web/app/assets/empty-state/search/member-dark.webp new file mode 100644 index 0000000..20521a5 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/member-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/member-light.webp b/plane-src/apps/web/app/assets/empty-state/search/member-light.webp new file mode 100644 index 0000000..dcd38c9 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/member-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/notification-dark.webp b/plane-src/apps/web/app/assets/empty-state/search/notification-dark.webp new file mode 100644 index 0000000..cb299d1 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/notification-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/notification-light.webp b/plane-src/apps/web/app/assets/empty-state/search/notification-light.webp new file mode 100644 index 0000000..55c4ffa Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/notification-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/project-dark.webp b/plane-src/apps/web/app/assets/empty-state/search/project-dark.webp new file mode 100644 index 0000000..98c6572 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/project-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/project-light.webp b/plane-src/apps/web/app/assets/empty-state/search/project-light.webp new file mode 100644 index 0000000..cb3e470 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/project-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/search-dark.webp b/plane-src/apps/web/app/assets/empty-state/search/search-dark.webp new file mode 100644 index 0000000..92abc61 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/search-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/search-light.webp b/plane-src/apps/web/app/assets/empty-state/search/search-light.webp new file mode 100644 index 0000000..807ed26 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/search-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/snooze-dark.webp b/plane-src/apps/web/app/assets/empty-state/search/snooze-dark.webp new file mode 100644 index 0000000..296e797 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/snooze-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/snooze-light.webp b/plane-src/apps/web/app/assets/empty-state/search/snooze-light.webp new file mode 100644 index 0000000..897ff35 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/snooze-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/views-dark.webp b/plane-src/apps/web/app/assets/empty-state/search/views-dark.webp new file mode 100644 index 0000000..96da5e3 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/views-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/search/views-light.webp b/plane-src/apps/web/app/assets/empty-state/search/views-light.webp new file mode 100644 index 0000000..3cd1d23 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/search/views-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/state_graph.svg b/plane-src/apps/web/app/assets/empty-state/state_graph.svg new file mode 100644 index 0000000..651ea34 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/state_graph.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/stickies/stickies-dark.webp b/plane-src/apps/web/app/assets/empty-state/stickies/stickies-dark.webp new file mode 100644 index 0000000..a93e44b Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/stickies/stickies-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/stickies/stickies-light.webp b/plane-src/apps/web/app/assets/empty-state/stickies/stickies-light.webp new file mode 100644 index 0000000..c7fd1f1 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/stickies/stickies-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/stickies/stickies-search-dark.webp b/plane-src/apps/web/app/assets/empty-state/stickies/stickies-search-dark.webp new file mode 100644 index 0000000..6afdc0c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/stickies/stickies-search-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/stickies/stickies-search-light.webp b/plane-src/apps/web/app/assets/empty-state/stickies/stickies-search-light.webp new file mode 100644 index 0000000..2b8ca8c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/stickies/stickies-search-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/view.svg b/plane-src/apps/web/app/assets/empty-state/view.svg new file mode 100644 index 0000000..478cf6e --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/view.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/web-hook.svg b/plane-src/apps/web/app/assets/empty-state/web-hook.svg new file mode 100644 index 0000000..f8e32d3 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/web-hook.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/all-dark.webp b/plane-src/apps/web/app/assets/empty-state/wiki/all-dark.webp new file mode 100644 index 0000000..81ef36d Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/wiki/all-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/all-filters-dark.svg b/plane-src/apps/web/app/assets/empty-state/wiki/all-filters-dark.svg new file mode 100644 index 0000000..037a2fd --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/wiki/all-filters-dark.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/all-filters-light.svg b/plane-src/apps/web/app/assets/empty-state/wiki/all-filters-light.svg new file mode 100644 index 0000000..8ecd8d2 --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/wiki/all-filters-light.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/all-light.webp b/plane-src/apps/web/app/assets/empty-state/wiki/all-light.webp new file mode 100644 index 0000000..02b1d88 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/wiki/all-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/archived-dark.webp b/plane-src/apps/web/app/assets/empty-state/wiki/archived-dark.webp new file mode 100644 index 0000000..64700c8 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/wiki/archived-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/archived-light.webp b/plane-src/apps/web/app/assets/empty-state/wiki/archived-light.webp new file mode 100644 index 0000000..9412f73 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/wiki/archived-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/name-filter-dark.svg b/plane-src/apps/web/app/assets/empty-state/wiki/name-filter-dark.svg new file mode 100644 index 0000000..969a1fe --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/wiki/name-filter-dark.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/name-filter-light.svg b/plane-src/apps/web/app/assets/empty-state/wiki/name-filter-light.svg new file mode 100644 index 0000000..bc2465a --- /dev/null +++ b/plane-src/apps/web/app/assets/empty-state/wiki/name-filter-light.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/navigation-pane/assets-dark.webp b/plane-src/apps/web/app/assets/empty-state/wiki/navigation-pane/assets-dark.webp new file mode 100644 index 0000000..c6bc05e Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/wiki/navigation-pane/assets-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/navigation-pane/assets-light.webp b/plane-src/apps/web/app/assets/empty-state/wiki/navigation-pane/assets-light.webp new file mode 100644 index 0000000..1e39829 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/wiki/navigation-pane/assets-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/navigation-pane/outline-dark.webp b/plane-src/apps/web/app/assets/empty-state/wiki/navigation-pane/outline-dark.webp new file mode 100644 index 0000000..fca82f1 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/wiki/navigation-pane/outline-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/navigation-pane/outline-light.webp b/plane-src/apps/web/app/assets/empty-state/wiki/navigation-pane/outline-light.webp new file mode 100644 index 0000000..6870e6c Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/wiki/navigation-pane/outline-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/private-dark.webp b/plane-src/apps/web/app/assets/empty-state/wiki/private-dark.webp new file mode 100644 index 0000000..b482f43 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/wiki/private-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/private-light.webp b/plane-src/apps/web/app/assets/empty-state/wiki/private-light.webp new file mode 100644 index 0000000..e1c3af9 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/wiki/private-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/public-dark.webp b/plane-src/apps/web/app/assets/empty-state/wiki/public-dark.webp new file mode 100644 index 0000000..571bf8a Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/wiki/public-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/wiki/public-light.webp b/plane-src/apps/web/app/assets/empty-state/wiki/public-light.webp new file mode 100644 index 0000000..e0f7b7d Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/wiki/public-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-draft/issue-dark.webp b/plane-src/apps/web/app/assets/empty-state/workspace-draft/issue-dark.webp new file mode 100644 index 0000000..2765335 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-draft/issue-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-draft/issue-light.webp b/plane-src/apps/web/app/assets/empty-state/workspace-draft/issue-light.webp new file mode 100644 index 0000000..8be274b Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-draft/issue-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark-resp.webp new file mode 100644 index 0000000..ae19eb4 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark.webp new file mode 100644 index 0000000..bddaf05 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/api-tokens-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/api-tokens-light-resp.webp new file mode 100644 index 0000000..2ec9bf8 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/api-tokens-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/api-tokens-light.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/api-tokens-light.webp new file mode 100644 index 0000000..ba3bc58 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/api-tokens-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/exports-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/exports-dark-resp.webp new file mode 100644 index 0000000..6417ffa Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/exports-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/exports-dark.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/exports-dark.webp new file mode 100644 index 0000000..b0911c9 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/exports-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/exports-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/exports-light-resp.webp new file mode 100644 index 0000000..29a695f Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/exports-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/exports-light.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/exports-light.webp new file mode 100644 index 0000000..5e08f21 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/exports-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/imports-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/imports-dark-resp.webp new file mode 100644 index 0000000..70e789b Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/imports-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/imports-dark.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/imports-dark.webp new file mode 100644 index 0000000..428ee97 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/imports-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/imports-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/imports-light-resp.webp new file mode 100644 index 0000000..9bc95ef Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/imports-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/imports-light.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/imports-light.webp new file mode 100644 index 0000000..5f42180 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/imports-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/integrations-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/integrations-dark-resp.webp new file mode 100644 index 0000000..3f9e009 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/integrations-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/integrations-dark.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/integrations-dark.webp new file mode 100644 index 0000000..603a658 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/integrations-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/integrations-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/integrations-light-resp.webp new file mode 100644 index 0000000..13e08be Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/integrations-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/integrations-light.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/integrations-light.webp new file mode 100644 index 0000000..eb62503 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/integrations-light.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark-resp.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark-resp.webp new file mode 100644 index 0000000..7923073 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark.webp new file mode 100644 index 0000000..88a584e Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/webhooks-light-resp.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/webhooks-light-resp.webp new file mode 100644 index 0000000..ecb0f28 Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/webhooks-light-resp.webp differ diff --git a/plane-src/apps/web/app/assets/empty-state/workspace-settings/webhooks-light.webp b/plane-src/apps/web/app/assets/empty-state/workspace-settings/webhooks-light.webp new file mode 100644 index 0000000..6c76b5a Binary files /dev/null and b/plane-src/apps/web/app/assets/empty-state/workspace-settings/webhooks-light.webp differ diff --git a/plane-src/apps/web/app/assets/favicon/apple-touch-icon.png b/plane-src/apps/web/app/assets/favicon/apple-touch-icon.png new file mode 100644 index 0000000..a631267 Binary files /dev/null and b/plane-src/apps/web/app/assets/favicon/apple-touch-icon.png differ diff --git a/plane-src/apps/web/app/assets/favicon/favicon-16x16.png b/plane-src/apps/web/app/assets/favicon/favicon-16x16.png new file mode 100644 index 0000000..af59ef0 Binary files /dev/null and b/plane-src/apps/web/app/assets/favicon/favicon-16x16.png differ diff --git a/plane-src/apps/web/app/assets/favicon/favicon-32x32.png b/plane-src/apps/web/app/assets/favicon/favicon-32x32.png new file mode 100644 index 0000000..16a1271 Binary files /dev/null and b/plane-src/apps/web/app/assets/favicon/favicon-32x32.png differ diff --git a/plane-src/apps/web/app/assets/favicon/favicon.ico b/plane-src/apps/web/app/assets/favicon/favicon.ico new file mode 100644 index 0000000..613b1a3 Binary files /dev/null and b/plane-src/apps/web/app/assets/favicon/favicon.ico differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/bold-italic.ttf b/plane-src/apps/web/app/assets/fonts/inter/bold-italic.ttf new file mode 100644 index 0000000..0d19c1a Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/bold-italic.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/bold.ttf b/plane-src/apps/web/app/assets/fonts/inter/bold.ttf new file mode 100644 index 0000000..cd13f60 Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/bold.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/heavy-italic.ttf b/plane-src/apps/web/app/assets/fonts/inter/heavy-italic.ttf new file mode 100644 index 0000000..b33602f Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/heavy-italic.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/heavy.ttf b/plane-src/apps/web/app/assets/fonts/inter/heavy.ttf new file mode 100644 index 0000000..89673de Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/heavy.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/light-italic.ttf b/plane-src/apps/web/app/assets/fonts/inter/light-italic.ttf new file mode 100644 index 0000000..f69e18b Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/light-italic.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/light.ttf b/plane-src/apps/web/app/assets/fonts/inter/light.ttf new file mode 100644 index 0000000..acae361 Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/light.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/medium-italic.ttf b/plane-src/apps/web/app/assets/fonts/inter/medium-italic.ttf new file mode 100644 index 0000000..5c8c8b1 Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/medium-italic.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/medium.ttf b/plane-src/apps/web/app/assets/fonts/inter/medium.ttf new file mode 100644 index 0000000..71d9017 Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/medium.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/regular-italic.ttf b/plane-src/apps/web/app/assets/fonts/inter/regular-italic.ttf new file mode 100644 index 0000000..14d3595 Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/regular-italic.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/regular.ttf b/plane-src/apps/web/app/assets/fonts/inter/regular.ttf new file mode 100644 index 0000000..ce097c8 Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/regular.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/semibold-italic.ttf b/plane-src/apps/web/app/assets/fonts/inter/semibold-italic.ttf new file mode 100644 index 0000000..d9c9896 Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/semibold-italic.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/semibold.ttf b/plane-src/apps/web/app/assets/fonts/inter/semibold.ttf new file mode 100644 index 0000000..053185e Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/semibold.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/thin-italic.ttf b/plane-src/apps/web/app/assets/fonts/inter/thin-italic.ttf new file mode 100644 index 0000000..134e837 Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/thin-italic.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/thin.ttf b/plane-src/apps/web/app/assets/fonts/inter/thin.ttf new file mode 100644 index 0000000..e68ec47 Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/thin.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/ultrabold-italic.ttf b/plane-src/apps/web/app/assets/fonts/inter/ultrabold-italic.ttf new file mode 100644 index 0000000..df45062 Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/ultrabold-italic.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/ultrabold.ttf b/plane-src/apps/web/app/assets/fonts/inter/ultrabold.ttf new file mode 100644 index 0000000..e71c601 Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/ultrabold.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/ultralight-italic.ttf b/plane-src/apps/web/app/assets/fonts/inter/ultralight-italic.ttf new file mode 100644 index 0000000..275f305 Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/ultralight-italic.ttf differ diff --git a/plane-src/apps/web/app/assets/fonts/inter/ultralight.ttf b/plane-src/apps/web/app/assets/fonts/inter/ultralight.ttf new file mode 100644 index 0000000..f9c6cfc Binary files /dev/null and b/plane-src/apps/web/app/assets/fonts/inter/ultralight.ttf differ diff --git a/plane-src/apps/web/app/assets/icons/icon-180x180.png b/plane-src/apps/web/app/assets/icons/icon-180x180.png new file mode 100644 index 0000000..e7142bc Binary files /dev/null and b/plane-src/apps/web/app/assets/icons/icon-180x180.png differ diff --git a/plane-src/apps/web/app/assets/icons/icon-512x512.png b/plane-src/apps/web/app/assets/icons/icon-512x512.png new file mode 100644 index 0000000..4c070d0 Binary files /dev/null and b/plane-src/apps/web/app/assets/icons/icon-512x512.png differ diff --git a/plane-src/apps/web/app/assets/images/logo-spinner-dark.gif b/plane-src/apps/web/app/assets/images/logo-spinner-dark.gif new file mode 100644 index 0000000..8bd0832 Binary files /dev/null and b/plane-src/apps/web/app/assets/images/logo-spinner-dark.gif differ diff --git a/plane-src/apps/web/app/assets/images/logo-spinner-light.gif b/plane-src/apps/web/app/assets/images/logo-spinner-light.gif new file mode 100644 index 0000000..8b57103 Binary files /dev/null and b/plane-src/apps/web/app/assets/images/logo-spinner-light.gif differ diff --git a/plane-src/apps/web/app/assets/instance-not-ready.webp b/plane-src/apps/web/app/assets/instance-not-ready.webp new file mode 100644 index 0000000..ceabbcc Binary files /dev/null and b/plane-src/apps/web/app/assets/instance-not-ready.webp differ diff --git a/plane-src/apps/web/app/assets/instance-setup-done.webp b/plane-src/apps/web/app/assets/instance-setup-done.webp new file mode 100644 index 0000000..4773ed7 Binary files /dev/null and b/plane-src/apps/web/app/assets/instance-setup-done.webp differ diff --git a/plane-src/apps/web/app/assets/instance/maintenance-mode-dark.svg b/plane-src/apps/web/app/assets/instance/maintenance-mode-dark.svg new file mode 100644 index 0000000..546125f --- /dev/null +++ b/plane-src/apps/web/app/assets/instance/maintenance-mode-dark.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/instance/maintenance-mode-light.svg b/plane-src/apps/web/app/assets/instance/maintenance-mode-light.svg new file mode 100644 index 0000000..6cd8bab --- /dev/null +++ b/plane-src/apps/web/app/assets/instance/maintenance-mode-light.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/logos/gitea-logo.svg b/plane-src/apps/web/app/assets/logos/gitea-logo.svg new file mode 100644 index 0000000..4329134 --- /dev/null +++ b/plane-src/apps/web/app/assets/logos/gitea-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plane-src/apps/web/app/assets/logos/github-black.png b/plane-src/apps/web/app/assets/logos/github-black.png new file mode 100644 index 0000000..7a7a824 Binary files /dev/null and b/plane-src/apps/web/app/assets/logos/github-black.png differ diff --git a/plane-src/apps/web/app/assets/logos/github-dark.svg b/plane-src/apps/web/app/assets/logos/github-dark.svg new file mode 100644 index 0000000..a0cb35c --- /dev/null +++ b/plane-src/apps/web/app/assets/logos/github-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/plane-src/apps/web/app/assets/logos/github-square.png b/plane-src/apps/web/app/assets/logos/github-square.png new file mode 100644 index 0000000..527ba79 Binary files /dev/null and b/plane-src/apps/web/app/assets/logos/github-square.png differ diff --git a/plane-src/apps/web/app/assets/logos/github-white.png b/plane-src/apps/web/app/assets/logos/github-white.png new file mode 100644 index 0000000..dbb2b57 Binary files /dev/null and b/plane-src/apps/web/app/assets/logos/github-white.png differ diff --git a/plane-src/apps/web/app/assets/logos/gitlab-logo.svg b/plane-src/apps/web/app/assets/logos/gitlab-logo.svg new file mode 100644 index 0000000..dab4d8b --- /dev/null +++ b/plane-src/apps/web/app/assets/logos/gitlab-logo.svg @@ -0,0 +1 @@ + diff --git a/plane-src/apps/web/app/assets/logos/google-logo.svg b/plane-src/apps/web/app/assets/logos/google-logo.svg new file mode 100644 index 0000000..088288f --- /dev/null +++ b/plane-src/apps/web/app/assets/logos/google-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plane-src/apps/web/app/assets/mac-command.svg b/plane-src/apps/web/app/assets/mac-command.svg new file mode 100644 index 0000000..5cee459 --- /dev/null +++ b/plane-src/apps/web/app/assets/mac-command.svg @@ -0,0 +1,2 @@ + +mac-command \ No newline at end of file diff --git a/plane-src/apps/web/app/assets/og-image.png b/plane-src/apps/web/app/assets/og-image.png new file mode 100644 index 0000000..dbf89c6 Binary files /dev/null and b/plane-src/apps/web/app/assets/og-image.png differ diff --git a/plane-src/apps/web/app/assets/onboarding/cycles.webp b/plane-src/apps/web/app/assets/onboarding/cycles.webp new file mode 100644 index 0000000..278803b Binary files /dev/null and b/plane-src/apps/web/app/assets/onboarding/cycles.webp differ diff --git a/plane-src/apps/web/app/assets/onboarding/issues.webp b/plane-src/apps/web/app/assets/onboarding/issues.webp new file mode 100644 index 0000000..5aed9e3 Binary files /dev/null and b/plane-src/apps/web/app/assets/onboarding/issues.webp differ diff --git a/plane-src/apps/web/app/assets/onboarding/modules.webp b/plane-src/apps/web/app/assets/onboarding/modules.webp new file mode 100644 index 0000000..974e464 Binary files /dev/null and b/plane-src/apps/web/app/assets/onboarding/modules.webp differ diff --git a/plane-src/apps/web/app/assets/onboarding/onboarding-pages.webp b/plane-src/apps/web/app/assets/onboarding/onboarding-pages.webp new file mode 100644 index 0000000..536f9ec Binary files /dev/null and b/plane-src/apps/web/app/assets/onboarding/onboarding-pages.webp differ diff --git a/plane-src/apps/web/app/assets/onboarding/pages.webp b/plane-src/apps/web/app/assets/onboarding/pages.webp new file mode 100644 index 0000000..27004d6 Binary files /dev/null and b/plane-src/apps/web/app/assets/onboarding/pages.webp differ diff --git a/plane-src/apps/web/app/assets/onboarding/views.webp b/plane-src/apps/web/app/assets/onboarding/views.webp new file mode 100644 index 0000000..9c958ec Binary files /dev/null and b/plane-src/apps/web/app/assets/onboarding/views.webp differ diff --git a/plane-src/apps/web/app/assets/plane-logos/black-horizontal-with-blue-logo.png b/plane-src/apps/web/app/assets/plane-logos/black-horizontal-with-blue-logo.png new file mode 100644 index 0000000..c14505a Binary files /dev/null and b/plane-src/apps/web/app/assets/plane-logos/black-horizontal-with-blue-logo.png differ diff --git a/plane-src/apps/web/app/assets/plane-logos/blue-without-text.png b/plane-src/apps/web/app/assets/plane-logos/blue-without-text.png new file mode 100644 index 0000000..ea94aec Binary files /dev/null and b/plane-src/apps/web/app/assets/plane-logos/blue-without-text.png differ diff --git a/plane-src/apps/web/app/assets/plane-logos/white-horizontal-with-blue-logo.png b/plane-src/apps/web/app/assets/plane-logos/white-horizontal-with-blue-logo.png new file mode 100644 index 0000000..97560fb Binary files /dev/null and b/plane-src/apps/web/app/assets/plane-logos/white-horizontal-with-blue-logo.png differ diff --git a/plane-src/apps/web/app/assets/plane-logos/white-horizontal.svg b/plane-src/apps/web/app/assets/plane-logos/white-horizontal.svg new file mode 100644 index 0000000..13e2dbb --- /dev/null +++ b/plane-src/apps/web/app/assets/plane-logos/white-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/plane-takeoff.png b/plane-src/apps/web/app/assets/plane-takeoff.png new file mode 100644 index 0000000..417ff82 Binary files /dev/null and b/plane-src/apps/web/app/assets/plane-takeoff.png differ diff --git a/plane-src/apps/web/app/assets/scribble/scribble-black.svg b/plane-src/apps/web/app/assets/scribble/scribble-black.svg new file mode 100644 index 0000000..556b956 --- /dev/null +++ b/plane-src/apps/web/app/assets/scribble/scribble-black.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/scribble/scribble-white.svg b/plane-src/apps/web/app/assets/scribble/scribble-white.svg new file mode 100644 index 0000000..2133897 --- /dev/null +++ b/plane-src/apps/web/app/assets/scribble/scribble-white.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/services/csv.svg b/plane-src/apps/web/app/assets/services/csv.svg new file mode 100644 index 0000000..adea1dd --- /dev/null +++ b/plane-src/apps/web/app/assets/services/csv.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/services/excel.svg b/plane-src/apps/web/app/assets/services/excel.svg new file mode 100644 index 0000000..86fb8e6 --- /dev/null +++ b/plane-src/apps/web/app/assets/services/excel.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/services/github.png b/plane-src/apps/web/app/assets/services/github.png new file mode 100644 index 0000000..527ba79 Binary files /dev/null and b/plane-src/apps/web/app/assets/services/github.png differ diff --git a/plane-src/apps/web/app/assets/services/jira.svg b/plane-src/apps/web/app/assets/services/jira.svg new file mode 100644 index 0000000..5e5cebc --- /dev/null +++ b/plane-src/apps/web/app/assets/services/jira.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/services/json.svg b/plane-src/apps/web/app/assets/services/json.svg new file mode 100644 index 0000000..0fe32e2 --- /dev/null +++ b/plane-src/apps/web/app/assets/services/json.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/plane-src/apps/web/app/assets/services/slack.png b/plane-src/apps/web/app/assets/services/slack.png new file mode 100644 index 0000000..fc91800 Binary files /dev/null and b/plane-src/apps/web/app/assets/services/slack.png differ diff --git a/plane-src/apps/web/app/assets/user.png b/plane-src/apps/web/app/assets/user.png new file mode 100644 index 0000000..bc02512 Binary files /dev/null and b/plane-src/apps/web/app/assets/user.png differ diff --git a/plane-src/apps/web/app/assets/users/user-1.png b/plane-src/apps/web/app/assets/users/user-1.png new file mode 100644 index 0000000..b69f539 Binary files /dev/null and b/plane-src/apps/web/app/assets/users/user-1.png differ diff --git a/plane-src/apps/web/app/assets/users/user-2.png b/plane-src/apps/web/app/assets/users/user-2.png new file mode 100644 index 0000000..c5b9bc5 Binary files /dev/null and b/plane-src/apps/web/app/assets/users/user-2.png differ diff --git a/plane-src/apps/web/app/assets/users/user-profile-cover-default-img.png b/plane-src/apps/web/app/assets/users/user-profile-cover-default-img.png new file mode 100644 index 0000000..6f555da Binary files /dev/null and b/plane-src/apps/web/app/assets/users/user-profile-cover-default-img.png differ diff --git a/plane-src/apps/web/app/assets/workspace-active-cycles/cta-l-1-dark.webp b/plane-src/apps/web/app/assets/workspace-active-cycles/cta-l-1-dark.webp new file mode 100644 index 0000000..901f4b3 Binary files /dev/null and b/plane-src/apps/web/app/assets/workspace-active-cycles/cta-l-1-dark.webp differ diff --git a/plane-src/apps/web/app/assets/workspace-active-cycles/cta-l-1-light.webp b/plane-src/apps/web/app/assets/workspace-active-cycles/cta-l-1-light.webp new file mode 100644 index 0000000..7cce304 Binary files /dev/null and b/plane-src/apps/web/app/assets/workspace-active-cycles/cta-l-1-light.webp differ diff --git a/plane-src/apps/web/app/assets/workspace-active-cycles/cta-r-1-dark.webp b/plane-src/apps/web/app/assets/workspace-active-cycles/cta-r-1-dark.webp new file mode 100644 index 0000000..3c3a32a Binary files /dev/null and b/plane-src/apps/web/app/assets/workspace-active-cycles/cta-r-1-dark.webp differ diff --git a/plane-src/apps/web/app/assets/workspace-active-cycles/cta-r-1-light.webp b/plane-src/apps/web/app/assets/workspace-active-cycles/cta-r-1-light.webp new file mode 100644 index 0000000..4911a27 Binary files /dev/null and b/plane-src/apps/web/app/assets/workspace-active-cycles/cta-r-1-light.webp differ diff --git a/plane-src/apps/web/app/assets/workspace-active-cycles/cta-r-2-dark.webp b/plane-src/apps/web/app/assets/workspace-active-cycles/cta-r-2-dark.webp new file mode 100644 index 0000000..0776cc7 Binary files /dev/null and b/plane-src/apps/web/app/assets/workspace-active-cycles/cta-r-2-dark.webp differ diff --git a/plane-src/apps/web/app/assets/workspace-active-cycles/cta-r-2-light.webp b/plane-src/apps/web/app/assets/workspace-active-cycles/cta-r-2-light.webp new file mode 100644 index 0000000..971d28f Binary files /dev/null and b/plane-src/apps/web/app/assets/workspace-active-cycles/cta-r-2-light.webp differ diff --git a/plane-src/apps/web/app/assets/workspace/workspace-creation-disabled.png b/plane-src/apps/web/app/assets/workspace/workspace-creation-disabled.png new file mode 100644 index 0000000..09e05ef Binary files /dev/null and b/plane-src/apps/web/app/assets/workspace/workspace-creation-disabled.png differ diff --git a/plane-src/apps/web/app/assets/workspace/workspace-not-available.png b/plane-src/apps/web/app/assets/workspace/workspace-not-available.png new file mode 100644 index 0000000..e95cdf0 Binary files /dev/null and b/plane-src/apps/web/app/assets/workspace/workspace-not-available.png differ diff --git a/plane-src/apps/web/app/compat/next/helper.ts b/plane-src/apps/web/app/compat/next/helper.ts new file mode 100644 index 0000000..c4edf3d --- /dev/null +++ b/plane-src/apps/web/app/compat/next/helper.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +/** + * Ensures that a URL has a trailing slash while preserving query parameters and fragments + * @param url - The URL to process + * @returns The URL with a trailing slash added to the pathname (if not already present) + */ +export function ensureTrailingSlash(url: string): string { + try { + const fallbackBaseUrl = + typeof window !== "undefined" && window.location.origin ? window.location.origin : "http://dummy.com"; + // Handle relative URLs by creating a URL object with a fallback base URL + const urlObj = new URL(url, fallbackBaseUrl); + + // Don't modify root path + if (urlObj.pathname === "/") { + return url; + } + + // Add trailing slash if it doesn't exist + if (!urlObj.pathname.endsWith("/")) { + urlObj.pathname += "/"; + } + + // For relative URLs, return just the path + search + hash + if (url.startsWith("/")) { + return urlObj.pathname + urlObj.search + urlObj.hash; + } + + // For absolute URLs, return the full URL + return urlObj.toString(); + } catch (error) { + // If URL parsing fails, return the original URL + console.warn("Failed to parse URL for trailing slash enforcement:", url, error); + return url; + } +} diff --git a/plane-src/apps/web/app/compat/next/image.tsx b/plane-src/apps/web/app/compat/next/image.tsx new file mode 100644 index 0000000..f229d0d --- /dev/null +++ b/plane-src/apps/web/app/compat/next/image.tsx @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; + +// Minimal shim so code using next/image compiles under React Router + Vite +// without changing call sites. It just renders a native img. + +type NextImageProps = React.ImgHTMLAttributes & { + src: string; + fill?: boolean; + priority?: boolean; + quality?: number; + placeholder?: "blur" | "empty"; + blurDataURL?: string; +}; + +function Image({ + src, + alt = "", + fill, + priority: _priority, + quality: _quality, + placeholder: _placeholder, + blurDataURL: _blurDataURL, + ...rest +}: NextImageProps) { + // If fill is true, apply object-fit styles + const style = fill ? { objectFit: "cover" as const, width: "100%", height: "100%" } : rest.style; + + return {alt}; +} + +export default Image; diff --git a/plane-src/apps/web/app/compat/next/link.tsx b/plane-src/apps/web/app/compat/next/link.tsx new file mode 100644 index 0000000..8517756 --- /dev/null +++ b/plane-src/apps/web/app/compat/next/link.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +import { Link as RRLink } from "react-router"; +import { ensureTrailingSlash } from "./helper"; + +type NextLinkProps = React.ComponentProps<"a"> & { + href: string; + replace?: boolean; + prefetch?: boolean; // next.js prop, ignored + scroll?: boolean; // next.js prop, ignored + shallow?: boolean; // next.js prop, ignored +}; + +function Link({ href, replace, prefetch: _prefetch, scroll: _scroll, shallow: _shallow, ...rest }: NextLinkProps) { + return ; +} + +export default Link; diff --git a/plane-src/apps/web/app/compat/next/navigation.ts b/plane-src/apps/web/app/compat/next/navigation.ts new file mode 100644 index 0000000..3803ffe --- /dev/null +++ b/plane-src/apps/web/app/compat/next/navigation.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useMemo } from "react"; +import { useLocation, useNavigate, useParams as useParamsRR, useSearchParams as useSearchParamsRR } from "react-router"; +import { ensureTrailingSlash } from "./helper"; + +export function useRouter() { + const navigate = useNavigate(); + return useMemo( + () => ({ + push: (to: string) => { + // Defer navigation to avoid state updates during render + setTimeout(() => navigate(ensureTrailingSlash(to)), 0); + }, + replace: (to: string) => { + // Defer navigation to avoid state updates during render + setTimeout(() => navigate(ensureTrailingSlash(to), { replace: true }), 0); + }, + back: () => { + setTimeout(() => navigate(-1), 0); + }, + forward: () => { + setTimeout(() => navigate(1), 0); + }, + refresh: () => { + location.reload(); + }, + prefetch: async (_to: string) => { + // no-op in this shim + }, + }), + [navigate] + ); +} + +export function usePathname(): string { + const { pathname } = useLocation(); + return pathname; +} + +export function useSearchParams(): URLSearchParams { + const [searchParams] = useSearchParamsRR(); + return searchParams; +} + +export function useParams() { + return useParamsRR(); +} diff --git a/plane-src/apps/web/app/compat/next/script.tsx b/plane-src/apps/web/app/compat/next/script.tsx new file mode 100644 index 0000000..c2b470b --- /dev/null +++ b/plane-src/apps/web/app/compat/next/script.tsx @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect } from "react"; + +type ScriptProps = { + src?: string; + id?: string; + strategy?: "beforeInteractive" | "afterInteractive" | "lazyOnload" | "worker"; + onLoad?: () => void; + onError?: () => void; + children?: string; + defer?: boolean; + [key: string]: any; +}; + +// Minimal shim for next/script that creates a script tag +function Script({ src, id, strategy: _strategy, onLoad, onError, children, ...rest }: ScriptProps) { + useEffect(() => { + if (src) { + const script = document.createElement("script"); + if (id) script.id = id; + script.src = src; + if (onLoad) script.onload = onLoad; + if (onError) script.onerror = onError; + Object.keys(rest).forEach((key) => { + script.setAttribute(key, rest[key]); + }); + document.body.appendChild(script); + + return () => { + if (script.parentNode) { + document.body.removeChild(script); + } + }; + } else if (children) { + const script = document.createElement("script"); + if (id) script.id = id; + script.textContent = children; + Object.keys(rest).forEach((key) => { + script.setAttribute(key, rest[key]); + }); + document.body.appendChild(script); + + return () => { + if (script.parentNode) { + document.body.removeChild(script); + } + }; + } + }, [src, id, children, onLoad, onError, rest]); + + return null; +} + +export default Script; diff --git a/plane-src/apps/web/app/entry.client.tsx b/plane-src/apps/web/app/entry.client.tsx new file mode 100644 index 0000000..9c665ed --- /dev/null +++ b/plane-src/apps/web/app/entry.client.tsx @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { HydratedRouter } from "react-router/dom"; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/plane-src/apps/web/app/error/dev.tsx b/plane-src/apps/web/app/error/dev.tsx new file mode 100644 index 0000000..0269dcb --- /dev/null +++ b/plane-src/apps/web/app/error/dev.tsx @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// plane imports +import { isRouteErrorResponse } from "react-router"; +import { Banner } from "@plane/propel/banner"; +import { Button } from "@plane/propel/button"; +import { Card, ECardVariant } from "@plane/propel/card"; +import { InfoFillIcon } from "@plane/propel/icons"; + +interface ErrorActionsProps { + onGoHome: () => void; + onReload?: () => void; +} + +function ErrorActions({ onGoHome, onReload }: ErrorActionsProps) { + return ( +
+ + {onReload && ( + + )} +
+ ); +} + +interface DevErrorComponentProps { + error: unknown; + onGoHome: () => void; + onReload: () => void; +} + +export function DevErrorComponent({ error, onGoHome, onReload }: DevErrorComponentProps) { + if (isRouteErrorResponse(error)) { + return ( +
+
+ } + title="Route Error Response" + animationDuration={0} + /> + + +
+
+

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

+
+
+ +
+

Error Data

+
+

{error.data}

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

Error

+
+
+ +
+

Message

+
+

{error.message}

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

Stack Trace

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

Development Mode

+

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

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

Unknown Error

+
+
+ +
+

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

+
+ + +
+ +
+
+ ); +} diff --git a/plane-src/apps/web/app/error/index.tsx b/plane-src/apps/web/app/error/index.tsx new file mode 100644 index 0000000..9c3712b --- /dev/null +++ b/plane-src/apps/web/app/error/index.tsx @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; +// layouts +import { DevErrorComponent } from "./dev"; +import { ProdErrorComponent } from "./prod"; + +export function CustomErrorComponent({ error }: { error: unknown }) { + // router + const router = useAppRouter(); + + const handleGoHome = () => router.push("/"); + const handleReload = () => window.location.reload(); + + if (import.meta.env.DEV) { + return ; + } + + return ; +} diff --git a/plane-src/apps/web/app/error/prod.tsx b/plane-src/apps/web/app/error/prod.tsx new file mode 100644 index 0000000..a8d6182 --- /dev/null +++ b/plane-src/apps/web/app/error/prod.tsx @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useTheme } from "next-themes"; +// plane imports +import { Button } from "@plane/propel/button"; +// assets +import maintenanceModeDarkModeImage from "@/app/assets/instance/maintenance-mode-dark.svg?url"; +import maintenanceModeLightModeImage from "@/app/assets/instance/maintenance-mode-light.svg?url"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; + +const linkMap = [ + { + key: "mail_to", + label: "Contact Support", + value: "mailto:support@plane.so", + }, + { + key: "status", + label: "Status Page", + value: "https://status.plane.so/", + }, + { + key: "twitter_handle", + label: "@planepowers", + value: "https://x.com/planepowers", + }, +]; + +// Production Error Component +interface ProdErrorComponentProps { + onGoHome: () => void; +} + +export function ProdErrorComponent({ onGoHome }: ProdErrorComponentProps) { + // hooks + const { resolvedTheme } = useTheme(); + + // derived values + const maintenanceModeImage = resolvedTheme === "dark" ? maintenanceModeDarkModeImage : maintenanceModeLightModeImage; + + return ( + +
+
+ ProjectSettingImg +
+
+
+

🚧 Looks like something went wrong!

+ + We track these errors automatically and working on getting things back up and running. If the problem + persists feel free to contact us. In the meantime, try refreshing. + +
+ +
+ {linkMap.map((link) => ( + + ))} +
+ +
+ +
+
+
+
+ ); +} diff --git a/plane-src/apps/web/app/layout.tsx b/plane-src/apps/web/app/layout.tsx new file mode 100644 index 0000000..81d1109 --- /dev/null +++ b/plane-src/apps/web/app/layout.tsx @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import Script from "next/script"; + +// styles +import "@/styles/globals.css"; + +import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants"; + +// helpers +import { cn } from "@plane/utils"; + +// assets +import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url"; +import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url"; +import faviconIco from "@/app/assets/favicon/favicon.ico?url"; +import icon180 from "@/app/assets/icons/icon-180x180.png?url"; +import icon512 from "@/app/assets/icons/icon-512x512.png?url"; + +// local +import { AppProvider } from "./provider"; + +export const meta = () => [ + { title: "NODE.DC | Self-hosted task management workspace." }, + { name: "description", content: SITE_DESCRIPTION }, + { + name: "keywords", + content: + "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", + }, + { + name: "viewport", + content: + "width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover", + }, + { property: "og:title", content: "NODE.DC | Self-hosted task management workspace." }, + { + property: "og:description", + content: "Self-hosted task management workspace for projects, work items, and internal operational flows.", + }, + { property: "og:url", content: "https://app.plane.so/" }, + { property: "og:image", content: "https://app.plane.so/og-image.png" }, + { property: "og:image:width", content: "1200" }, + { property: "og:image:height", content: "630" }, + { property: "og:image:alt", content: "NODE.DC - Self-hosted task management workspace" }, + { name: "twitter:site", content: "@nodedc" }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:image", content: "https://app.plane.so/og-image.png" }, + { name: "twitter:image:width", content: "1200" }, + { name: "twitter:image:height", content: "630" }, + { name: "twitter:image:alt", content: "NODE.DC - Self-hosted task management workspace" }, +]; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const isSessionRecorderEnabled = parseInt(process.env.VITE_ENABLE_SESSION_RECORDER || "0"); + + return ( + + + + + + + + {/* Meta info for PWA */} + + + + + + + + + + + + +
+
+ +
+
{children}
+
+
+ + {!!isSessionRecorderEnabled && process.env.VITE_SESSION_RECORDER_KEY && ( + + )} + + ); +} diff --git a/plane-src/apps/web/app/not-found.tsx b/plane-src/apps/web/app/not-found.tsx new file mode 100644 index 0000000..1376395 --- /dev/null +++ b/plane-src/apps/web/app/not-found.tsx @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import Link from "next/link"; +// ui +import { Button } from "@plane/propel/button"; +// images +import Image404 from "@/app/assets/404.svg?url"; +// types +import type { Route } from "./+types/not-found"; + +export const meta: Route.MetaFunction = () => [ + { title: "404 - Page Not Found" }, + { name: "robots", content: "noindex, nofollow" }, +]; + +function PageNotFound() { + return ( +
+
+
+
+ 404- Page not found +
+
+

Oops! Something went wrong.

+

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

+
+ + + + + +
+
+
+ ); +} + +export default PageNotFound; diff --git a/plane-src/apps/web/app/provider.tsx b/plane-src/apps/web/app/provider.tsx new file mode 100644 index 0000000..016b66c --- /dev/null +++ b/plane-src/apps/web/app/provider.tsx @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { lazy, Suspense } from "react"; +import { useTheme } from "next-themes"; +import { SWRConfig } from "swr"; +// Plane Imports +import { WEB_SWR_CONFIG } from "@plane/constants"; +import { TranslationProvider } from "@plane/i18n"; +import { Toast } from "@plane/propel/toast"; +// helpers +import { resolveGeneralTheme } from "@plane/utils"; +// mobx store provider +import { StoreProvider } from "@/lib/store-context"; + +// lazy imports +const AppProgressBar = lazy(function AppProgressBar() { + return import("@/lib/b-progress/AppProgressBar"); +}); + +const StoreWrapper = lazy(function StoreWrapper() { + return import("@/lib/wrappers/store-wrapper"); +}); + +const InstanceWrapper = lazy(function InstanceWrapper() { + return import("@/lib/wrappers/instance-wrapper"); +}); + +export interface IAppProvider { + children: React.ReactNode; +} + +export function AppProvider(props: IAppProvider) { + const { children } = props; + // themes + const { resolvedTheme } = useTheme(); + + return ( + + <> + + + + + + + {children} + + + + + + + ); +} diff --git a/plane-src/apps/web/app/root.tsx b/plane-src/apps/web/app/root.tsx new file mode 100644 index 0000000..e390f35 --- /dev/null +++ b/plane-src/apps/web/app/root.tsx @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { ReactNode } from "react"; +import Script from "next/script"; +import { Links, Meta, Outlet, Scripts } from "react-router"; +import type { LinksFunction } from "react-router"; +import { ThemeProvider, useTheme } from "next-themes"; +// plane imports +import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants"; +import { cn } from "@plane/utils"; +// types +// assets +import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url"; +import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url"; +import faviconIco from "@/app/assets/favicon/favicon.ico?url"; +import icon180 from "@/app/assets/icons/icon-180x180.png?url"; +import icon512 from "@/app/assets/icons/icon-512x512.png?url"; +import ogImage from "@/app/assets/og-image.png?url"; +import globalStyles from "@/styles/globals.css?url"; +import type { Route } from "./+types/root"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +// local +import { CustomErrorComponent } from "./error"; +import { AppProvider } from "./provider"; +// fonts +import "@fontsource-variable/inter"; +import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url"; +import "@fontsource/material-symbols-rounded"; +import "@fontsource/ibm-plex-mono"; + +const APP_TITLE = "NODE.DC | Self-hosted task management workspace."; + +export const links: LinksFunction = () => [ + { rel: "icon", type: "image/png", sizes: "32x32", href: favicon32 }, + { rel: "icon", type: "image/png", sizes: "16x16", href: favicon16 }, + { rel: "shortcut icon", href: faviconIco }, + { rel: "manifest", href: "/site.webmanifest.json" }, + { rel: "apple-touch-icon", href: icon512 }, + { rel: "apple-touch-icon", sizes: "180x180", href: icon180 }, + { rel: "apple-touch-icon", sizes: "512x512", href: icon512 }, + { rel: "manifest", href: "/manifest.json" }, + { rel: "stylesheet", href: globalStyles }, + { + rel: "preload", + href: interVariableWoff2, + as: "font", + type: "font/woff2", + crossOrigin: "anonymous", + }, +]; + +export function Layout({ children }: { children: ReactNode }) { + const isSessionRecorderEnabled = parseInt(process.env.VITE_ENABLE_SESSION_RECORDER || "0"); + + return ( + + + + + + {/* Meta info for PWA */} + + + + + + + + + + +
+
+ + {children} + + + {!!isSessionRecorderEnabled && process.env.VITE_SESSION_RECORDER_KEY && ( + + )} + + + ); +} + +export const meta: Route.MetaFunction = () => [ + { title: APP_TITLE }, + { name: "description", content: SITE_DESCRIPTION }, + { property: "og:title", content: APP_TITLE }, + { + property: "og:description", + content: "Self-hosted task management workspace for projects, work items, and internal operational flows.", + }, + { property: "og:url", content: "https://app.plane.so/" }, + { property: "og:image", content: ogImage }, + { property: "og:image:width", content: "1200" }, + { property: "og:image:height", content: "630" }, + { property: "og:image:alt", content: "NODE.DC - Self-hosted task management workspace" }, + { + name: "keywords", + content: + "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", + }, + { name: "twitter:site", content: "@nodedc" }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:image", content: ogImage }, + { name: "twitter:image:width", content: "1200" }, + { name: "twitter:image:height", content: "630" }, + { name: "twitter:image:alt", content: "NODE.DC - Self-hosted task management workspace" }, +]; + +export default function Root() { + return ( + +
+
+ +
+
+
+ ); +} + +export function HydrateFallback() { + const { resolvedTheme } = useTheme(); + + // if we are on the server or the theme is not resolved, return an empty div + if (typeof window === "undefined" || resolvedTheme === undefined) return
; + + return ( +
+ +
+ ); +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + return ; +} diff --git a/plane-src/apps/web/app/routes.ts b/plane-src/apps/web/app/routes.ts new file mode 100644 index 0000000..cdfea2e --- /dev/null +++ b/plane-src/apps/web/app/routes.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { route } from "@react-router/dev/routes"; +import type { RouteConfigEntry } from "@react-router/dev/routes"; +import { coreRoutes } from "./routes/core"; +import { extendedRoutes } from "./routes/extended"; +import { mergeRoutes } from "./routes/helper"; + +/** + * Main Routes Configuration + * This file serves as the entry point for the route configuration. + */ +const mergedRoutes: RouteConfigEntry[] = mergeRoutes(coreRoutes, extendedRoutes); + +// Add catch-all route at the end (404 handler) +const routes: RouteConfigEntry[] = [...mergedRoutes, route("*", "./not-found.tsx")]; + +export default routes; diff --git a/plane-src/apps/web/app/routes/core.ts b/plane-src/apps/web/app/routes/core.ts new file mode 100644 index 0000000..c9c82bd --- /dev/null +++ b/plane-src/apps/web/app/routes/core.ts @@ -0,0 +1,405 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { index, layout, route } from "@react-router/dev/routes"; +import type { RouteConfig, RouteConfigEntry } from "@react-router/dev/routes"; + +export const coreRoutes: RouteConfigEntry[] = [ + // ======================================================================== + // USER MANAGEMENT ROUTES + // ======================================================================== + + // Home - Sign In + layout("./(home)/layout.tsx", [index("./(home)/page.tsx")]), + + // Sign Up + layout("./(all)/sign-up/layout.tsx", [route("sign-up", "./(all)/sign-up/page.tsx")]), + + // Account Routes - Password Management + layout("./(all)/accounts/forgot-password/layout.tsx", [ + route("accounts/forgot-password", "./(all)/accounts/forgot-password/page.tsx"), + ]), + layout("./(all)/accounts/reset-password/layout.tsx", [ + route("accounts/reset-password", "./(all)/accounts/reset-password/page.tsx"), + ]), + layout("./(all)/accounts/set-password/layout.tsx", [ + route("accounts/set-password", "./(all)/accounts/set-password/page.tsx"), + ]), + + // Create Workspace + layout("./(all)/create-workspace/layout.tsx", [route("create-workspace", "./(all)/create-workspace/page.tsx")]), + + // Onboarding + layout("./(all)/onboarding/layout.tsx", [route("onboarding", "./(all)/onboarding/page.tsx")]), + + // Invitations + layout("./(all)/invitations/layout.tsx", [route("invitations", "./(all)/invitations/page.tsx")]), + + // Workspace Invitations + layout("./(all)/workspace-invitations/layout.tsx", [ + route("workspace-invitations", "./(all)/workspace-invitations/page.tsx"), + ]), + + // ======================================================================== + // ALL APP ROUTES + // ======================================================================== + layout("./(all)/layout.tsx", [ + // ====================================================================== + // WORKSPACE-SCOPED ROUTES + // ====================================================================== + layout("./(all)/[workspaceSlug]/layout.tsx", [ + // ==================================================================== + // PROJECTS APP SECTION - WORKSPACE LEVEL ROUTES + // ==================================================================== + layout("./(all)/[workspaceSlug]/(projects)/layout.tsx", [ + // -------------------------------------------------------------------- + // WORKSPACE LEVEL ROUTES + // -------------------------------------------------------------------- + + // Workspace Home + route(":workspaceSlug", "./(all)/[workspaceSlug]/(projects)/page.tsx"), + + // Active Cycles + layout("./(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx", [ + route(":workspaceSlug/active-cycles", "./(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx"), + ]), + + // Analytics + layout("./(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx", [ + route(":workspaceSlug/analytics/:tabId", "./(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx"), + ]), + + // Browse + layout("./(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx", [ + route(":workspaceSlug/browse/:workItem", "./(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx"), + ]), + + // Drafts + layout("./(all)/[workspaceSlug]/(projects)/drafts/layout.tsx", [ + route(":workspaceSlug/drafts", "./(all)/[workspaceSlug]/(projects)/drafts/page.tsx"), + ]), + + // Notifications + layout("./(all)/[workspaceSlug]/(projects)/notifications/layout.tsx", [ + route(":workspaceSlug/notifications", "./(all)/[workspaceSlug]/(projects)/notifications/page.tsx"), + ]), + + // Profile + layout("./(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx", [ + route(":workspaceSlug/profile/:userId", "./(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx"), + route( + ":workspaceSlug/profile/:userId/:profileViewId", + "./(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx" + ), + route( + ":workspaceSlug/profile/:userId/activity", + "./(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx" + ), + ]), + + // Stickies + layout("./(all)/[workspaceSlug]/(projects)/stickies/layout.tsx", [ + route(":workspaceSlug/stickies", "./(all)/[workspaceSlug]/(projects)/stickies/page.tsx"), + ]), + + // Workspace Views + layout("./(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx", [ + route(":workspaceSlug/workspace-views", "./(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx"), + route( + ":workspaceSlug/workspace-views/:globalViewId", + "./(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx" + ), + ]), + + // Archived Projects + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [ + route( + ":workspaceSlug/projects/archives", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx" + ), + ]), + + // -------------------------------------------------------------------- + // PROJECT LEVEL ROUTES + // -------------------------------------------------------------------- + + // Project List + layout("./(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx", [ + route(":workspaceSlug/projects", "./(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx"), + ]), + + // Project Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [ + // Project Issues List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/issues", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx" + ), + ]), + // Issue Detail + route( + ":workspaceSlug/projects/:projectId/issues/:issueId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx" + ), + + // Cycle Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/cycles/:cycleId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx" + ), + ]), + + // Cycles List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/cycles", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx" + ), + ]), + + // Module Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/modules/:moduleId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx" + ), + ]), + + // Modules List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/modules", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx" + ), + ]), + + // View Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/views/:viewId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx" + ), + ]), + + // Views List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/views", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx" + ), + ]), + + // Page Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/pages/:pageId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx" + ), + ]), + + // Pages List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/pages", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx" + ), + ]), + // Intake list + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/intake", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx" + ), + ]), + ]), + + // Project Archives - Issues, Cycles, Modules + // Project Archives - Issues - List + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/archives/issues", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx" + ), + ]), + + // Project Archives - Issues - Detail + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/archives/issues/:archivedIssueId", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx" + ), + ]), + + // Project Archives - Cycles + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/archives/cycles", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx" + ), + ]), + + // Project Archives - Modules + layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx", [ + route( + ":workspaceSlug/projects/:projectId/archives/modules", + "./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx" + ), + ]), + ]), + + // ==================================================================== + // SETTINGS SECTION + // ==================================================================== + layout("./(all)/[workspaceSlug]/(settings)/layout.tsx", [ + // -------------------------------------------------------------------- + // WORKSPACE SETTINGS + // -------------------------------------------------------------------- + + layout("./(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx", [ + route(":workspaceSlug/settings", "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx"), + route( + ":workspaceSlug/settings/members", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx" + ), + route( + ":workspaceSlug/settings/billing", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx" + ), + route( + ":workspaceSlug/settings/exports", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx" + ), + route( + ":workspaceSlug/settings/webhooks", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx" + ), + route( + ":workspaceSlug/settings/webhooks/:webhookId", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx" + ), + ]), + + // -------------------------------------------------------------------- + // PROJECT SETTINGS + // -------------------------------------------------------------------- + + layout("./(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx", [ + // No Projects available page + route(":workspaceSlug/settings/projects", "./(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx"), + layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx", [ + // Project Settings + route( + ":workspaceSlug/settings/projects/:projectId", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx" + ), + // Project Members + route( + ":workspaceSlug/settings/projects/:projectId/members", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx" + ), + // Project Features + route( + ":workspaceSlug/settings/projects/:projectId/features/cycles", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx" + ), + route( + ":workspaceSlug/settings/projects/:projectId/features/modules", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx" + ), + route( + ":workspaceSlug/settings/projects/:projectId/features/views", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx" + ), + route( + ":workspaceSlug/settings/projects/:projectId/features/pages", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx" + ), + route( + ":workspaceSlug/settings/projects/:projectId/features/intake", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx" + ), + // Project States + route( + ":workspaceSlug/settings/projects/:projectId/states", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx" + ), + // Project Labels + route( + ":workspaceSlug/settings/projects/:projectId/labels", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx" + ), + // Project Estimates + route( + ":workspaceSlug/settings/projects/:projectId/estimates", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx" + ), + // Project Automations + layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx", [ + route( + ":workspaceSlug/settings/projects/:projectId/automations", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx" + ), + ]), + ]), + ]), + ]), + ]), + // ====================================================================== + // STANDALONE ROUTES (outside workspace context) + // ====================================================================== + + // -------------------------------------------------------------------- + // PROFILE SETTINGS + // -------------------------------------------------------------------- + + layout("./(all)/settings/profile/layout.tsx", [ + route("settings/profile/:profileTabId", "./(all)/settings/profile/[profileTabId]/page.tsx"), + ]), + ]), + + // ======================================================================== + // REDIRECT ROUTES + // ======================================================================== + // Legacy URL redirects for backward compatibility + + // -------------------------------------------------------------------- + // REDIRECT ROUTES + // -------------------------------------------------------------------- + + // Project settings redirect: /:workspaceSlug/projects/:projectId/settings/:path* + // → /:workspaceSlug/settings/projects/:projectId/:path* + route(":workspaceSlug/projects/:projectId/settings/*", "routes/redirects/core/project-settings.tsx"), + + // Analytics redirect: /:workspaceSlug/analytics → /:workspaceSlug/analytics/overview + route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"), + + // API tokens redirect: /:workspaceSlug/settings/api-tokens + // → /settings/profile/api-tokens + route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"), + + // Inbox redirect: /:workspaceSlug/projects/:projectId/inbox + // → /:workspaceSlug/projects/:projectId/intake + route(":workspaceSlug/projects/:projectId/inbox", "routes/redirects/core/inbox.tsx"), + + // Sign-up redirects + route("accounts/sign-up", "routes/redirects/core/accounts-signup.tsx"), + + // Sign-in redirects (all redirect to home page) + route("sign-in", "routes/redirects/core/sign-in.tsx"), + route("signin", "routes/redirects/core/signin.tsx"), + route("login", "routes/redirects/core/login.tsx"), + + // Register redirect + route("register", "routes/redirects/core/register.tsx"), + + // Profile settings redirects + route("profile/*", "routes/redirects/core/profile-settings.tsx"), + + // Account settings redirects + route(":workspaceSlug/settings/account/*", "routes/redirects/core/workspace-account-settings.tsx"), +] satisfies RouteConfig; diff --git a/plane-src/apps/web/app/routes/extended.ts b/plane-src/apps/web/app/routes/extended.ts new file mode 100644 index 0000000..452d214 --- /dev/null +++ b/plane-src/apps/web/app/routes/extended.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { RouteConfigEntry } from "@react-router/dev/routes"; + +export const extendedRoutes: RouteConfigEntry[] = []; diff --git a/plane-src/apps/web/app/routes/helper.ts b/plane-src/apps/web/app/routes/helper.ts new file mode 100644 index 0000000..74dcab5 --- /dev/null +++ b/plane-src/apps/web/app/routes/helper.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { RouteConfigEntry } from "@react-router/dev/routes"; + +/** + * Merges two route configurations intelligently. + * - Deep merges children when the same layout file exists in both arrays + * - Deduplicates routes by file property, preferring extended over core + * - Maintains order: core routes first, then extended routes at each level + */ +export function mergeRoutes(core: RouteConfigEntry[], extended: RouteConfigEntry[]): RouteConfigEntry[] { + // Step 1: Create a Map to track routes by file path + const routeMap = new Map(); + + // Step 2: Process core routes first + for (const coreRoute of core) { + const fileKey = coreRoute.file; + routeMap.set(fileKey, coreRoute); + } + + // Step 3: Process extended routes + for (const extendedRoute of extended) { + const fileKey = extendedRoute.file; + + if (routeMap.has(fileKey)) { + // Route exists in both - need to merge + const coreRoute = routeMap.get(fileKey)!; + + // Check if both have children (layouts that need deep merging) + if (coreRoute.children && extendedRoute.children) { + // Deep merge: recursively merge children + const mergedChildren = mergeRoutes( + Array.isArray(coreRoute.children) ? coreRoute.children : [], + Array.isArray(extendedRoute.children) ? extendedRoute.children : [] + ); + routeMap.set(fileKey, { + ...extendedRoute, + children: mergedChildren, + }); + } else { + // No children or only one has children - prefer extended + routeMap.set(fileKey, extendedRoute); + } + } else { + // Route only exists in extended + routeMap.set(fileKey, extendedRoute); + } + } + + // Step 4: Build final array maintaining order (core first, then extended-only) + const result: RouteConfigEntry[] = []; + + // Add all core routes (now merged or original) + for (const coreRoute of core) { + const fileKey = coreRoute.file; + if (routeMap.has(fileKey)) { + result.push(routeMap.get(fileKey)!); + routeMap.delete(fileKey); // Remove so we don't add it again + } + } + + // Add remaining extended-only routes + for (const extendedRoute of extended) { + const fileKey = extendedRoute.file; + if (routeMap.has(fileKey)) { + result.push(routeMap.get(fileKey)!); + routeMap.delete(fileKey); + } + } + + return result; +} diff --git a/plane-src/apps/web/app/routes/redirects/core/accounts-signup.tsx b/plane-src/apps/web/app/routes/redirects/core/accounts-signup.tsx new file mode 100644 index 0000000..be1ca73 --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/core/accounts-signup.tsx @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { redirect } from "react-router"; + +export const clientLoader = () => { + throw redirect("/sign-up/"); +}; + +export default function AccountsSignup() { + return null; +} diff --git a/plane-src/apps/web/app/routes/redirects/core/analytics.tsx b/plane-src/apps/web/app/routes/redirects/core/analytics.tsx new file mode 100644 index 0000000..3cc7c81 --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/core/analytics.tsx @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { redirect } from "react-router"; +import type { Route } from "./+types/analytics"; + +export const clientLoader = ({ params }: Route.ClientLoaderArgs) => { + const { workspaceSlug } = params; + throw redirect(`/${workspaceSlug}/analytics/overview/`); +}; + +export default function Analytics() { + return null; +} diff --git a/plane-src/apps/web/app/routes/redirects/core/api-tokens.tsx b/plane-src/apps/web/app/routes/redirects/core/api-tokens.tsx new file mode 100644 index 0000000..9d070a1 --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/core/api-tokens.tsx @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { redirect } from "react-router"; + +export const clientLoader = () => { + throw redirect(`/settings/profile/api-tokens/`); +}; + +export default function ApiTokens() { + return null; +} diff --git a/plane-src/apps/web/app/routes/redirects/core/inbox.tsx b/plane-src/apps/web/app/routes/redirects/core/inbox.tsx new file mode 100644 index 0000000..2d71229 --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/core/inbox.tsx @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { redirect } from "react-router"; +import type { Route } from "./+types/inbox"; + +export const clientLoader = ({ params }: Route.ClientLoaderArgs) => { + const { workspaceSlug, projectId } = params; + throw redirect(`/${workspaceSlug}/projects/${projectId}/intake/`); +}; + +export default function Inbox() { + return null; +} diff --git a/plane-src/apps/web/app/routes/redirects/core/index.ts b/plane-src/apps/web/app/routes/redirects/core/index.ts new file mode 100644 index 0000000..e1eaeca --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/core/index.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { route } from "@react-router/dev/routes"; +import type { RouteConfigEntry } from "@react-router/dev/routes"; + +export const coreRedirectRoutes: RouteConfigEntry[] = [ + // ======================================================================== + // WORKSPACE & PROJECT REDIRECTS + // ======================================================================== + + // Project settings redirect: /:workspaceSlug/projects/:projectId/settings/:path* + // → /:workspaceSlug/settings/projects/:projectId/:path* + route(":workspaceSlug/projects/:projectId/settings/*", "routes/redirects/core/project-settings.tsx"), + + // Analytics redirect: /:workspaceSlug/analytics → /:workspaceSlug/analytics/overview + route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"), + + // API tokens redirect: /:workspaceSlug/settings/api-tokens + // → /settings/profile/api-tokens + route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"), + + // Inbox redirect: /:workspaceSlug/projects/:projectId/inbox + // → /:workspaceSlug/projects/:projectId/intake + route(":workspaceSlug/projects/:projectId/inbox", "routes/redirects/core/inbox.tsx"), + + // ======================================================================== + // AUTHENTICATION REDIRECTS + // ======================================================================== + + // Sign-up redirects + route("accounts/sign-up", "routes/redirects/core/accounts-signup.tsx"), + + // Sign-in redirects (all redirect to home page) + route("sign-in", "routes/redirects/core/sign-in.tsx"), + route("signin", "routes/redirects/core/signin.tsx"), + route("login", "routes/redirects/core/login.tsx"), + + // Register redirect + route("register", "routes/redirects/core/register.tsx"), +]; diff --git a/plane-src/apps/web/app/routes/redirects/core/login.tsx b/plane-src/apps/web/app/routes/redirects/core/login.tsx new file mode 100644 index 0000000..d591a33 --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/core/login.tsx @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { redirect } from "react-router"; + +export const clientLoader = () => { + throw redirect("/"); +}; + +export default function Login() { + return null; +} diff --git a/plane-src/apps/web/app/routes/redirects/core/profile-settings.tsx b/plane-src/apps/web/app/routes/redirects/core/profile-settings.tsx new file mode 100644 index 0000000..82362df --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/core/profile-settings.tsx @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { redirect } from "react-router"; +import type { Route } from "./+types/profile-settings"; + +export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => { + const searchParams = new URL(request.url).searchParams; + const splat = params["*"] || ""; + throw redirect(`/settings/profile/${splat || "general"}?${searchParams.toString()}`); +}; + +export default function ProfileSettings() { + return null; +} diff --git a/plane-src/apps/web/app/routes/redirects/core/project-settings.tsx b/plane-src/apps/web/app/routes/redirects/core/project-settings.tsx new file mode 100644 index 0000000..db74d57 --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/core/project-settings.tsx @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { redirect } from "react-router"; +import type { Route } from "./+types/project-settings"; + +export const clientLoader = ({ params }: Route.ClientLoaderArgs) => { + const { workspaceSlug, projectId } = params; + const splat = params["*"] || ""; + const destination = `/${workspaceSlug}/settings/projects/${projectId}${splat ? `/${splat}` : ""}/`; + throw redirect(destination); +}; + +export default function ProjectSettings() { + return null; +} diff --git a/plane-src/apps/web/app/routes/redirects/core/register.tsx b/plane-src/apps/web/app/routes/redirects/core/register.tsx new file mode 100644 index 0000000..bdcb453 --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/core/register.tsx @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { redirect } from "react-router"; + +export const clientLoader = () => { + throw redirect("/sign-up/"); +}; + +export default function Register() { + return null; +} diff --git a/plane-src/apps/web/app/routes/redirects/core/sign-in.tsx b/plane-src/apps/web/app/routes/redirects/core/sign-in.tsx new file mode 100644 index 0000000..5016e25 --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/core/sign-in.tsx @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { redirect } from "react-router"; + +export const clientLoader = () => { + throw redirect("/"); +}; + +export default function SignIn() { + return null; +} diff --git a/plane-src/apps/web/app/routes/redirects/core/signin.tsx b/plane-src/apps/web/app/routes/redirects/core/signin.tsx new file mode 100644 index 0000000..f557230 --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/core/signin.tsx @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { redirect } from "react-router"; + +export const clientLoader = () => { + throw redirect("/"); +}; + +export default function Signin() { + return null; +} diff --git a/plane-src/apps/web/app/routes/redirects/core/workspace-account-settings.tsx b/plane-src/apps/web/app/routes/redirects/core/workspace-account-settings.tsx new file mode 100644 index 0000000..2e44a5b --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/core/workspace-account-settings.tsx @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { redirect } from "react-router"; +import type { Route } from "./+types/workspace-account-settings"; + +export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => { + const searchParams = new URL(request.url).searchParams; + const splat = params["*"] || ""; + throw redirect(`/settings/profile/${splat || "general"}?${searchParams.toString()}`); +}; + +export default function WorkspaceAccountSettings() { + return null; +} diff --git a/plane-src/apps/web/app/routes/redirects/extended/index.ts b/plane-src/apps/web/app/routes/redirects/extended/index.ts new file mode 100644 index 0000000..7b4da92 --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/extended/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { RouteConfigEntry } from "@react-router/dev/routes"; + +export const extendedRedirectRoutes: RouteConfigEntry[] = []; diff --git a/plane-src/apps/web/app/routes/redirects/index.ts b/plane-src/apps/web/app/routes/redirects/index.ts new file mode 100644 index 0000000..b736b22 --- /dev/null +++ b/plane-src/apps/web/app/routes/redirects/index.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { RouteConfigEntry } from "@react-router/dev/routes"; +import { coreRedirectRoutes } from "./core"; +import { extendedRedirectRoutes } from "./extended"; + +/** + * REDIRECT ROUTES + * Centralized configuration for all route redirects + * Migrated from Next.js next.config.js redirects + */ +export const redirectRoutes: RouteConfigEntry[] = [...coreRedirectRoutes, ...extendedRedirectRoutes]; diff --git a/plane-src/apps/web/app/types/next-link.d.ts b/plane-src/apps/web/app/types/next-link.d.ts new file mode 100644 index 0000000..c724e3a --- /dev/null +++ b/plane-src/apps/web/app/types/next-link.d.ts @@ -0,0 +1,12 @@ +declare module "next/link" { + type Props = React.ComponentProps<"a"> & { + href: string; + replace?: boolean; + prefetch?: boolean; + scroll?: boolean; + shallow?: boolean; + }; + + const Link: React.FC; + export default Link; +} diff --git a/plane-src/apps/web/app/types/next-navigation.d.ts b/plane-src/apps/web/app/types/next-navigation.d.ts new file mode 100644 index 0000000..67a80c4 --- /dev/null +++ b/plane-src/apps/web/app/types/next-navigation.d.ts @@ -0,0 +1,14 @@ +declare module "next/navigation" { + export function useRouter(): { + push: (url: string) => void; + replace: (url: string) => void; + back: () => void; + forward: () => void; + refresh: () => void; + prefetch: (url: string) => Promise; + }; + + export function usePathname(): string; + export function useSearchParams(): URLSearchParams; + export function useParams>(): T; +} diff --git a/plane-src/apps/web/app/types/next-script.d.ts b/plane-src/apps/web/app/types/next-script.d.ts new file mode 100644 index 0000000..299b5ed --- /dev/null +++ b/plane-src/apps/web/app/types/next-script.d.ts @@ -0,0 +1,15 @@ +declare module "next/script" { + type ScriptProps = { + src?: string; + id?: string; + strategy?: "beforeInteractive" | "afterInteractive" | "lazyOnload" | "worker"; + onLoad?: () => void; + onError?: () => void; + children?: string; + defer?: boolean; + [key: string]: any; + }; + + const Script: React.FC; + export default Script; +} diff --git a/plane-src/apps/web/app/types/react-router-virtual.d.ts b/plane-src/apps/web/app/types/react-router-virtual.d.ts new file mode 100644 index 0000000..abf3b63 --- /dev/null +++ b/plane-src/apps/web/app/types/react-router-virtual.d.ts @@ -0,0 +1,5 @@ +declare module "virtual:react-router/server-build" { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const build: any; + export default build; +} diff --git a/plane-src/apps/web/ce/components/active-cycles/index.ts b/plane-src/apps/web/ce/components/active-cycles/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/plane-src/apps/web/ce/components/active-cycles/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./root"; diff --git a/plane-src/apps/web/ce/components/active-cycles/root.tsx b/plane-src/apps/web/ce/components/active-cycles/root.tsx new file mode 100644 index 0000000..8180565 --- /dev/null +++ b/plane-src/apps/web/ce/components/active-cycles/root.tsx @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// local imports +import { WorkspaceActiveCyclesUpgrade } from "./workspace-active-cycles-upgrade"; + +export function WorkspaceActiveCyclesRoot() { + return ; +} diff --git a/plane-src/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx b/plane-src/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx new file mode 100644 index 0000000..ff1ee25 --- /dev/null +++ b/plane-src/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx @@ -0,0 +1,134 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { AlertOctagon, BarChart4, CircleDashed, Folder, Microscope } from "lucide-react"; +// plane imports +import { MARKETING_PRICING_PAGE_LINK } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { getButtonStyling } from "@plane/propel/button"; +import { SearchIcon } from "@plane/propel/icons"; +import { ContentWrapper } from "@plane/ui"; +import { cn } from "@plane/utils"; +// assets +import ctaL1Dark from "@/app/assets/workspace-active-cycles/cta-l-1-dark.webp?url"; +import ctaL1Light from "@/app/assets/workspace-active-cycles/cta-l-1-light.webp?url"; +import ctaR1Dark from "@/app/assets/workspace-active-cycles/cta-r-1-dark.webp?url"; +import ctaR1Light from "@/app/assets/workspace-active-cycles/cta-r-1-light.webp?url"; +import ctaR2Dark from "@/app/assets/workspace-active-cycles/cta-r-2-dark.webp?url"; +import ctaR2Light from "@/app/assets/workspace-active-cycles/cta-r-2-light.webp?url"; +// components +import { ProIcon } from "@/components/common/pro-icon"; +// hooks +import { useUser } from "@/hooks/store/user"; + +export const WORKSPACE_ACTIVE_CYCLES_DETAILS = [ + { + key: "10000_feet_view", + title: "10,000-feet view of all active cycles.", + description: + "Zoom out to see running cycles across all your projects at once instead of going from Cycle to Cycle in each project.", + icon: Folder, + }, + { + key: "get_snapshot_of_each_active_cycle", + title: "Get a snapshot of each active cycle.", + description: + "Track high-level metrics for all active cycles, see their state of progress, and get a sense of scope against deadlines.", + icon: CircleDashed, + }, + { + key: "compare_burndowns", + title: "Compare burndowns.", + description: "Monitor how each of your teams are performing with a peek into each cycle’s burndown report.", + icon: BarChart4, + }, + { + key: "quickly_see_make_or_break_issues", + title: "Quickly see make-or-break work items. ", + description: + "Preview high-priority work items for each cycle against due dates. See all of them per cycle in one click.", + icon: AlertOctagon, + }, + { + key: "zoom_into_cycles_that_need_attention", + title: "Zoom into cycles that need attention. ", + description: "Investigate the state of any cycle that doesn’t conform to expectations in one click.", + icon: SearchIcon, + }, + { + key: "stay_ahead_of_blockers", + title: "Stay ahead of blockers.", + description: + "Spot challenges from one project to another and see inter-cycle dependencies that aren’t obvious from any other view.", + icon: Microscope, + }, +]; + +export const WorkspaceActiveCyclesUpgrade = observer(function WorkspaceActiveCyclesUpgrade() { + const { t } = useTranslation(); + // store hooks + const { + userProfile: { data: userProfile }, + } = useUser(); + + const isDarkMode = userProfile?.theme.theme === "dark"; + + return ( + +
+
+
+

{t("on_demand_snapshots_of_all_your_cycles")}

+

{t("active_cycles_description")}

+
+ + + l-1 + +
+
+ + r-1 + + + r-2 + +
+
+
+ {WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => ( +
+
+

{t(item.key)}

+ +
+ {t(`${item.key}_description`)} +
+ ))} +
+
+ ); +}); diff --git a/plane-src/apps/web/ce/components/analytics/tabs.tsx b/plane-src/apps/web/ce/components/analytics/tabs.tsx new file mode 100644 index 0000000..1fdbc0c --- /dev/null +++ b/plane-src/apps/web/ce/components/analytics/tabs.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { AnalyticsTab } from "@plane/types"; +import { Overview } from "@/components/analytics/overview"; +import { WorkItems } from "@/components/analytics/work-items"; + +export const getAnalyticsTabs = (t: (key: string, params?: Record) => string): AnalyticsTab[] => [ + { key: "overview", label: t("common.overview"), content: Overview, isDisabled: false }, + { key: "work-items", label: t("sidebar.work_items"), content: WorkItems, isDisabled: false }, +]; diff --git a/plane-src/apps/web/ce/components/analytics/use-analytics-tabs.tsx b/plane-src/apps/web/ce/components/analytics/use-analytics-tabs.tsx new file mode 100644 index 0000000..d022d74 --- /dev/null +++ b/plane-src/apps/web/ce/components/analytics/use-analytics-tabs.tsx @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useMemo } from "react"; +import { useTranslation } from "@plane/i18n"; +import { getAnalyticsTabs } from "./tabs"; + +export const useAnalyticsTabs = (_workspaceSlug: string) => { + const { t } = useTranslation(); + + const analyticsTabs = useMemo(() => getAnalyticsTabs(t), [t]); + + return analyticsTabs; +}; diff --git a/plane-src/apps/web/ce/components/app-rail/app-rail-hoc.tsx b/plane-src/apps/web/ce/components/app-rail/app-rail-hoc.tsx new file mode 100644 index 0000000..82f25e1 --- /dev/null +++ b/plane-src/apps/web/ce/components/app-rail/app-rail-hoc.tsx @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// hoc/withDockItems.tsx +import React from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { PlaneNewIcon } from "@plane/propel/icons"; +import type { AppSidebarItemData } from "@/components/sidebar/sidebar-item"; +import { useWorkspacePaths } from "@/hooks/use-workspace-paths"; + +type WithDockItemsProps = { + dockItems: (AppSidebarItemData & { shouldRender: boolean })[]; +}; + +export function withDockItems

(WrappedComponent: React.ComponentType

) { + const ComponentWithDockItems = observer(function ComponentWithDockItems(props: Omit) { + const { workspaceSlug } = useParams(); + const { isProjectsPath, isNotificationsPath } = useWorkspacePaths(); + + const dockItems: (AppSidebarItemData & { shouldRender: boolean })[] = [ + { + label: "Projects", + icon: , + href: `/${workspaceSlug}/`, + isActive: isProjectsPath && !isNotificationsPath, + shouldRender: true, + }, + ]; + + return ; + }); + + return ComponentWithDockItems; +} diff --git a/plane-src/apps/web/ce/components/app-rail/index.ts b/plane-src/apps/web/ce/components/app-rail/index.ts new file mode 100644 index 0000000..8b6ba42 --- /dev/null +++ b/plane-src/apps/web/ce/components/app-rail/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./app-rail-hoc"; diff --git a/plane-src/apps/web/ce/components/automations/list/wrapper.tsx b/plane-src/apps/web/ce/components/automations/list/wrapper.tsx new file mode 100644 index 0000000..f884b40 --- /dev/null +++ b/plane-src/apps/web/ce/components/automations/list/wrapper.tsx @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +type Props = { + projectId: string; + workspaceSlug: string; + children: React.ReactNode; +}; + +export function AutomationsListWrapper(props: Props) { + return <>{props.children}; +} diff --git a/plane-src/apps/web/ce/components/automations/root.tsx b/plane-src/apps/web/ce/components/automations/root.tsx new file mode 100644 index 0000000..1baeeb8 --- /dev/null +++ b/plane-src/apps/web/ce/components/automations/root.tsx @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; + +export type TCustomAutomationsRootProps = { + projectId: string; + workspaceSlug: string; +}; + +export function CustomAutomationsRoot(_props: TCustomAutomationsRootProps) { + return <>; +} diff --git a/plane-src/apps/web/ce/components/breadcrumbs/common.tsx b/plane-src/apps/web/ce/components/breadcrumbs/common.tsx new file mode 100644 index 0000000..9040cb2 --- /dev/null +++ b/plane-src/apps/web/ce/components/breadcrumbs/common.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// local components +import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences"; +import { ProjectBreadcrumb } from "./project"; + +type TCommonProjectBreadcrumbProps = { + workspaceSlug: string; + projectId: string; +}; + +export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) { + const { workspaceSlug, projectId } = props; + // preferences + const { preferences: projectPreferences } = useProjectNavigationPreferences(); + + if (projectPreferences.navigationMode === "TABBED") return null; + return ; +} diff --git a/plane-src/apps/web/ce/components/breadcrumbs/project-feature.tsx b/plane-src/apps/web/ce/components/breadcrumbs/project-feature.tsx new file mode 100644 index 0000000..4b076a7 --- /dev/null +++ b/plane-src/apps/web/ce/components/breadcrumbs/project-feature.tsx @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { ReactNode } from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { EProjectFeatureKey } from "@plane/constants"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +// local imports +import { getProjectFeatureNavigation } from "../projects/navigation/helper"; + +type TProjectFeatureBreadcrumbProps = { + workspaceSlug: string; + projectId: string; + featureKey: EProjectFeatureKey; + isLast?: boolean; + additionalNavigationItems?: TNavigationItem[]; +}; + +export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcrumb( + props: TProjectFeatureBreadcrumbProps +) { + const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props; + // store hooks + const { getPartialProjectById } = useProject(); + // derived values + const project = getPartialProjectById(projectId); + + if (!project) return null; + + const navigationItems = getProjectFeatureNavigation(workspaceSlug, projectId, project); + + // if additional navigation items are provided, add them to the navigation items + const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems]; + + const currentNavigationItem = allNavigationItems.find((item) => item.key === featureKey); + const icon = currentNavigationItem?.icon as ReactNode; + const name = currentNavigationItem?.name; + const href = currentNavigationItem?.href; + + return ( + <> + {icon}} + /> + } + showSeparator={false} + isLast={isLast} + /> + + ); +}); diff --git a/plane-src/apps/web/ce/components/breadcrumbs/project.tsx b/plane-src/apps/web/ce/components/breadcrumbs/project.tsx new file mode 100644 index 0000000..9793ecc --- /dev/null +++ b/plane-src/apps/web/ce/components/breadcrumbs/project.tsx @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { Logo } from "@plane/propel/emoji-icon-picker"; +import { ProjectIcon } from "@plane/propel/icons"; +// plane imports +import type { ICustomSearchSelectOption } from "@plane/types"; +import { BreadcrumbNavigationSearchDropdown, Breadcrumbs } from "@plane/ui"; +import { SwitcherLabel } from "@/components/common/switcher-label"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import type { TProject } from "@/plane-web/types"; + +type TProjectBreadcrumbProps = { + workspaceSlug: string; + projectId: string; + handleOnClick?: () => void; +}; + +export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TProjectBreadcrumbProps) { + const { workspaceSlug, projectId, handleOnClick } = props; + // router + const router = useAppRouter(); + // store hooks + const { joinedProjectIds, getPartialProjectById } = useProject(); + const currentProjectDetails = getPartialProjectById(projectId); + + // store hooks + + if (!currentProjectDetails) return null; + + // derived values + const switcherOptions = joinedProjectIds + .map((projectId) => { + const project = getPartialProjectById(projectId); + return { + value: projectId, + query: project?.name, + content: ( + + ), + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + // helpers + const renderIcon = (projectDetails: TProject) => ( + + + + ); + + return ( + <> + { + router.push(`/${workspaceSlug}/projects/${value}/issues`); + }} + title={currentProjectDetails?.name} + icon={renderIcon(currentProjectDetails)} + handleOnClick={() => { + if (handleOnClick) handleOnClick(); + else router.push(`/${workspaceSlug}/projects/${currentProjectDetails.id}/issues/`); + }} + shouldTruncate + /> + } + showSeparator={false} + /> + + ); +}); diff --git a/plane-src/apps/web/ce/components/browse/workItem-detail.tsx b/plane-src/apps/web/ce/components/browse/workItem-detail.tsx new file mode 100644 index 0000000..b56a9d9 --- /dev/null +++ b/plane-src/apps/web/ce/components/browse/workItem-detail.tsx @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import type { TIssue } from "@plane/types"; +import { IssueDetailRoot } from "@/components/issues/issue-detail/root"; + +export type TWorkItemDetailRoot = { + workspaceSlug: string; + projectId: string; + issueId: string; + issue: TIssue | undefined; +}; + +export const WorkItemDetailRoot = observer(function WorkItemDetailRoot(props: TWorkItemDetailRoot) { + const { workspaceSlug, projectId, issueId, issue } = props; + + return ( + + ); +}); diff --git a/plane-src/apps/web/ce/components/command-palette/actions/index.ts b/plane-src/apps/web/ce/components/command-palette/actions/index.ts new file mode 100644 index 0000000..a6961e2 --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/actions/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./work-item-actions"; diff --git a/plane-src/apps/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx b/plane-src/apps/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx new file mode 100644 index 0000000..69e4aed --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { EIconSize } from "@plane/constants"; +// plane imports +import { CheckIcon, StateGroupIcon } from "@plane/propel/icons"; +import { Spinner } from "@plane/ui"; +// store hooks +import { useProjectState } from "@/hooks/store/use-project-state"; + +export type TChangeWorkItemStateListProps = { + projectId: string | null; + currentStateId: string | null; + handleStateChange: (stateId: string) => void; +}; + +export const ChangeWorkItemStateList = observer(function ChangeWorkItemStateList(props: TChangeWorkItemStateListProps) { + const { projectId, currentStateId, handleStateChange } = props; + // store hooks + const { getProjectStates } = useProjectState(); + // derived values + const projectStates = getProjectStates(projectId); + + return ( + <> + {projectStates ? ( + projectStates.length > 0 ? ( + projectStates.map((state) => ( + handleStateChange(state.id)} className="focus:outline-none"> +

+ +

{state.name}

+
+
{state.id === currentStateId && }
+ + )) + ) : ( +
No states found
+ ) + ) : ( + + )} + + ); +}); diff --git a/plane-src/apps/web/ce/components/command-palette/actions/work-item-actions/index.ts b/plane-src/apps/web/ce/components/command-palette/actions/work-item-actions/index.ts new file mode 100644 index 0000000..3f2f8c0 --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/actions/work-item-actions/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./change-state-list"; diff --git a/plane-src/apps/web/ce/components/command-palette/helpers.tsx b/plane-src/apps/web/ce/components/command-palette/helpers.tsx new file mode 100644 index 0000000..d691579 --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/helpers.tsx @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { LayoutGrid } from "lucide-react"; +// plane imports +import { CycleIcon, ModuleIcon, PageIcon, ProjectIcon, ViewsIcon } from "@plane/propel/icons"; +import type { + IWorkspaceDefaultSearchResult, + IWorkspaceIssueSearchResult, + IWorkspacePageSearchResult, + IWorkspaceProjectSearchResult, + IWorkspaceSearchResult, +} from "@plane/types"; +import { generateWorkItemLink } from "@plane/utils"; +// plane web components +import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; + +export type TCommandGroups = { + [key: string]: { + icon: React.ReactNode | null; + itemName: (item: any) => React.ReactNode; + path: (item: any, projectId: string | undefined) => string; + title: string; + }; +}; + +export const commandGroups: TCommandGroups = { + cycle: { + icon: , + itemName: (cycle: IWorkspaceDefaultSearchResult) => ( +
+ {cycle.project__identifier} {cycle.name} +
+ ), + path: (cycle: IWorkspaceDefaultSearchResult) => + `/${cycle?.workspace__slug}/projects/${cycle?.project_id}/cycles/${cycle?.id}`, + title: "Cycles", + }, + issue: { + icon: null, + itemName: (issue: IWorkspaceIssueSearchResult) => ( +
+ {" "} + {issue.name} +
+ ), + path: (issue: IWorkspaceIssueSearchResult) => + generateWorkItemLink({ + workspaceSlug: issue?.workspace__slug, + projectId: issue?.project_id, + issueId: issue?.id, + projectIdentifier: issue.project__identifier, + sequenceId: issue?.sequence_id, + }), + title: "Work items", + }, + issue_view: { + icon: , + itemName: (view: IWorkspaceDefaultSearchResult) => ( +
+ {view.project__identifier} {view.name} +
+ ), + path: (view: IWorkspaceDefaultSearchResult) => + `/${view?.workspace__slug}/projects/${view?.project_id}/views/${view?.id}`, + title: "Views", + }, + module: { + icon: , + itemName: (module: IWorkspaceDefaultSearchResult) => ( +
+ {module.project__identifier} {module.name} +
+ ), + path: (module: IWorkspaceDefaultSearchResult) => + `/${module?.workspace__slug}/projects/${module?.project_id}/modules/${module?.id}`, + title: "Modules", + }, + page: { + icon: , + itemName: (page: IWorkspacePageSearchResult) => ( +
+ {page.project__identifiers?.[0]} {page.name} +
+ ), + path: (page: IWorkspacePageSearchResult, projectId: string | undefined) => { + let redirectProjectId = page?.project_ids?.[0]; + if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId; + return redirectProjectId + ? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}` + : `/${page?.workspace__slug}/wiki/${page?.id}`; + }, + title: "Pages", + }, + project: { + icon: , + itemName: (project: IWorkspaceProjectSearchResult) => project?.name, + path: (project: IWorkspaceProjectSearchResult) => `/${project?.workspace__slug}/projects/${project?.id}/issues/`, + title: "Projects", + }, + workspace: { + icon: , + itemName: (workspace: IWorkspaceSearchResult) => workspace?.name, + path: (workspace: IWorkspaceSearchResult) => `/${workspace?.slug}/`, + title: "Workspaces", + }, +}; diff --git a/plane-src/apps/web/ce/components/command-palette/index.ts b/plane-src/apps/web/ce/components/command-palette/index.ts new file mode 100644 index 0000000..128f77f --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/index.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./actions"; +export * from "./helpers"; diff --git a/plane-src/apps/web/ce/components/command-palette/modals/project-level.tsx b/plane-src/apps/web/ce/components/command-palette/modals/project-level.tsx new file mode 100644 index 0000000..e01b002 --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/modals/project-level.tsx @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// components +import { CycleCreateUpdateModal } from "@/components/cycles/modal"; +import { CreateUpdateModuleModal } from "@/components/modules"; +import { CreatePageModal } from "@/components/pages/modals/create-page-modal"; +import { CreateUpdateProjectViewModal } from "@/components/views/modal"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +// plane web hooks +import { EPageStoreType } from "@/plane-web/hooks/store"; + +export type TProjectLevelModalsProps = { + workspaceSlug: string; + projectId: string; +}; + +export const ProjectLevelModals = observer(function ProjectLevelModals(props: TProjectLevelModalsProps) { + const { workspaceSlug, projectId } = props; + // store hooks + const { + isCreateCycleModalOpen, + toggleCreateCycleModal, + isCreateModuleModalOpen, + toggleCreateModuleModal, + isCreateViewModalOpen, + toggleCreateViewModal, + createPageModal, + toggleCreatePageModal, + } = useCommandPalette(); + + return ( + <> + toggleCreateCycleModal(false)} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId.toString()} + /> + toggleCreateModuleModal(false)} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId.toString()} + /> + toggleCreateViewModal(false)} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId.toString()} + /> + toggleCreatePageModal({ isOpen: false })} + redirectionEnabled + storeType={EPageStoreType.PROJECT} + /> + + ); +}); diff --git a/plane-src/apps/web/ce/components/command-palette/modals/work-item-level.tsx b/plane-src/apps/web/ce/components/command-palette/modals/work-item-level.tsx new file mode 100644 index 0000000..fc83af4 --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/modals/work-item-level.tsx @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import type { TIssue } from "@plane/types"; +import { EIssueServiceType, EIssuesStoreType } from "@plane/types"; +// components +import { BulkDeleteIssuesModal } from "@/components/core/modals/bulk-delete-issues-modal"; +import { DeleteIssueModal } from "@/components/issues/delete-issue-modal"; +import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useUser } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useIssuesActions } from "@/hooks/use-issues-actions"; + +export type TWorkItemLevelModalsProps = { + workItemIdentifier: string | undefined; +}; + +export const WorkItemLevelModals = observer(function WorkItemLevelModals(props: TWorkItemLevelModalsProps) { + const { workItemIdentifier } = props; + // router + const { workspaceSlug, cycleId, moduleId } = useParams(); + const router = useAppRouter(); + // store hooks + const { data: currentUser } = useUser(); + const { + issue: { getIssueById, getIssueIdByIdentifier }, + } = useIssueDetail(); + // derived values + const workItemId = workItemIdentifier ? getIssueIdByIdentifier(workItemIdentifier) : undefined; + const workItemDetails = workItemId ? getIssueById(workItemId) : undefined; + + const { removeIssue: removeEpic } = useIssuesActions(EIssuesStoreType.EPIC); + const { removeIssue: removeWorkItem } = useIssuesActions(EIssuesStoreType.PROJECT); + + const { + isCreateIssueModalOpen, + toggleCreateIssueModal, + isDeleteIssueModalOpen, + toggleDeleteIssueModal, + isBulkDeleteIssueModalOpen, + toggleBulkDeleteIssueModal, + createWorkItemAllowedProjectIds, + } = useCommandPalette(); + // derived values + const { fetchSubIssues: fetchSubWorkItems } = useIssueDetail(); + const { fetchSubIssues: fetchEpicSubWorkItems } = useIssueDetail(EIssueServiceType.EPICS); + + const handleDeleteIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + const isEpic = workItemDetails?.is_epic; + const deleteAction = isEpic ? removeEpic : removeWorkItem; + const redirectPath = `/${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}`; + + await deleteAction(projectId, issueId); + router.push(redirectPath); + } catch (error) { + console.error("Failed to delete issue:", error); + } + }; + + const handleCreateIssueSubmit = async (newIssue: TIssue) => { + if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== workItemDetails?.id) return; + + const fetchAction = workItemDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems; + await fetchAction(workspaceSlug?.toString(), newIssue.project_id, workItemDetails.id); + }; + + const getCreateIssueModalData = () => { + if (cycleId) return { cycle_id: cycleId.toString() }; + if (moduleId) return { module_ids: [moduleId.toString()] }; + return undefined; + }; + + return ( + <> + toggleCreateIssueModal(false)} + data={getCreateIssueModalData()} + onSubmit={handleCreateIssueSubmit} + allowedProjectIds={createWorkItemAllowedProjectIds} + /> + {workspaceSlug && workItemId && workItemDetails && workItemDetails.project_id && ( + toggleDeleteIssueModal(false)} + isOpen={isDeleteIssueModalOpen} + data={workItemDetails} + onSubmit={() => + handleDeleteIssue(workspaceSlug.toString(), workItemDetails.project_id!, workItemId?.toString()) + } + isEpic={workItemDetails?.is_epic} + /> + )} + toggleBulkDeleteIssueModal(false)} + user={currentUser} + /> + + ); +}); diff --git a/plane-src/apps/web/ce/components/command-palette/modals/workspace-level.tsx b/plane-src/apps/web/ce/components/command-palette/modals/workspace-level.tsx new file mode 100644 index 0000000..e07f5d1 --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/modals/workspace-level.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// components +import { CreateProjectModal } from "@/components/project/create-project-modal"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; + +export type TWorkspaceLevelModalsProps = { + workspaceSlug: string; +}; + +export const WorkspaceLevelModals = observer(function WorkspaceLevelModals(props: TWorkspaceLevelModalsProps) { + const { workspaceSlug } = props; + // store hooks + const { isCreateProjectModalOpen, toggleCreateProjectModal } = useCommandPalette(); + + return ( + <> + toggleCreateProjectModal(false)} + workspaceSlug={workspaceSlug.toString()} + /> + + ); +}); diff --git a/plane-src/apps/web/ce/components/command-palette/power-k/constants.ts b/plane-src/apps/web/ce/components/command-palette/power-k/constants.ts new file mode 100644 index 0000000..870a4f4 --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/power-k/constants.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// core +import type { TPowerKModalPageDetails } from "@/components/power-k/ui/modal/constants"; +// local imports +import type { TPowerKPageTypeExtended } from "./types"; + +export const POWER_K_MODAL_PAGE_DETAILS_EXTENDED: Record = {}; diff --git a/plane-src/apps/web/ce/components/command-palette/power-k/context-detector.ts b/plane-src/apps/web/ce/components/command-palette/power-k/context-detector.ts new file mode 100644 index 0000000..42dcc20 --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/power-k/context-detector.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Params } from "react-router"; +// local imports +import type { TPowerKContextTypeExtended } from "./types"; + +export const detectExtendedContextFromURL = (_params: Params): TPowerKContextTypeExtended | null => null; diff --git a/plane-src/apps/web/ce/components/command-palette/power-k/hooks/use-extended-context-indicator.ts b/plane-src/apps/web/ce/components/command-palette/power-k/hooks/use-extended-context-indicator.ts new file mode 100644 index 0000000..e24bc08 --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/power-k/hooks/use-extended-context-indicator.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// local imports +import type { TPowerKContextType } from "@/components/power-k/core/types"; + +type TArgs = { + activeContext: TPowerKContextType | null; +}; + +export const useExtendedContextIndicator = (_args: TArgs): string | null => null; diff --git a/plane-src/apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts b/plane-src/apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/power-k/pages/context-based/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./root"; diff --git a/plane-src/apps/web/ce/components/command-palette/power-k/pages/context-based/root.tsx b/plane-src/apps/web/ce/components/command-palette/power-k/pages/context-based/root.tsx new file mode 100644 index 0000000..1cac3d3 --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/power-k/pages/context-based/root.tsx @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import type { TPowerKCommandConfig } from "@/components/power-k/core/types"; +import type { ContextBasedActionsProps, TContextEntityMap } from "@/components/power-k/ui/pages/context-based"; +// local imports +import type { TPowerKContextTypeExtended } from "../../types"; + +export const CONTEXT_ENTITY_MAP_EXTENDED: Record = {}; + +export function PowerKContextBasedActionsExtended(_props: ContextBasedActionsProps) { + return null; +} + +export const usePowerKContextBasedExtendedActions = (): TPowerKCommandConfig[] => []; diff --git a/plane-src/apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx b/plane-src/apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx new file mode 100644 index 0000000..3188e1b --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/power-k/pages/context-based/work-item/state-menu-item.tsx @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +// plane types +import { StateGroupIcon } from "@plane/propel/icons"; +import type { IState } from "@plane/types"; +// components +import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item"; + +export type TPowerKProjectStatesMenuItemsProps = { + handleSelect: (stateId: string) => void; + projectId: string | undefined; + selectedStateId: string | undefined; + states: IState[]; + workspaceSlug: string; +}; + +export const PowerKProjectStatesMenuItems = observer(function PowerKProjectStatesMenuItems( + props: TPowerKProjectStatesMenuItemsProps +) { + const { handleSelect, selectedStateId, states } = props; + + return ( + <> + {states.map((state) => ( + } + label={state.name} + isSelected={state.id === selectedStateId} + onSelect={() => handleSelect(state.id)} + /> + ))} + + ); +}); diff --git a/plane-src/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx b/plane-src/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx new file mode 100644 index 0000000..af4debf --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/power-k/search/no-results-command.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Command } from "cmdk"; +import { useTranslation } from "@plane/i18n"; +import { SearchIcon } from "@plane/propel/icons"; +// plane imports +// components +import type { TPowerKContext } from "@/components/power-k/core/types"; +// plane web imports +import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item"; + +export type TPowerKModalNoSearchResultsCommandProps = { + context: TPowerKContext; + searchTerm: string; + updateSearchTerm: (value: string) => void; +}; + +export function PowerKModalNoSearchResultsCommand(props: TPowerKModalNoSearchResultsCommandProps) { + const { updateSearchTerm } = props; + // translation + const { t } = useTranslation(); + + return ( + + + {t("power_k.search_menu.no_results")}{" "} + {t("power_k.search_menu.clear_search")} +

+ } + onSelect={() => updateSearchTerm("")} + /> +
+ ); +} diff --git a/plane-src/apps/web/ce/components/command-palette/power-k/search/search-results-map.tsx b/plane-src/apps/web/ce/components/command-palette/power-k/search/search-results-map.tsx new file mode 100644 index 0000000..c137f6f --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/power-k/search/search-results-map.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import type { TPowerKSearchResultGroupDetails } from "@/components/power-k/ui/modal/search-results-map"; +// local imports +import type { TPowerKSearchResultsKeysExtended } from "../types"; + +type TSearchResultsGroupsMapExtended = Record; + +export const SEARCH_RESULTS_GROUPS_MAP_EXTENDED: TSearchResultsGroupsMapExtended = {}; diff --git a/plane-src/apps/web/ce/components/command-palette/power-k/types.ts b/plane-src/apps/web/ce/components/command-palette/power-k/types.ts new file mode 100644 index 0000000..9d0086f --- /dev/null +++ b/plane-src/apps/web/ce/components/command-palette/power-k/types.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export type TPowerKContextTypeExtended = never; + +export type TPowerKPageTypeExtended = never; + +export type TPowerKSearchResultsKeysExtended = never; diff --git a/plane-src/apps/web/ce/components/comments/comment-block.tsx b/plane-src/apps/web/ce/components/comments/comment-block.tsx new file mode 100644 index 0000000..c08454e --- /dev/null +++ b/plane-src/apps/web/ce/components/comments/comment-block.tsx @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { ReactNode } from "react"; +import { useRef } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { CommentReplyIcon } from "@plane/propel/icons"; +import type { TIssueComment } from "@plane/types"; +import { cn } from "@plane/utils"; +// hooks + +type TCommentBlock = { + comment: TIssueComment; + ends: "top" | "bottom" | undefined; + children: ReactNode; +}; + +export const CommentBlock = observer(function CommentBlock(props: TCommentBlock) { + const { comment, ends, children } = props; + const commentBlockRef = useRef(null); + + if (!comment) return null; + return ( +
+
+
+
+
+
+ {children} +
+
+
+ ); +}); diff --git a/plane-src/apps/web/ce/components/comments/index.ts b/plane-src/apps/web/ce/components/comments/index.ts new file mode 100644 index 0000000..3bbc477 --- /dev/null +++ b/plane-src/apps/web/ce/components/comments/index.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./comment-block"; +export { CommentCardDisplay } from "@/components/comments/card/display"; diff --git a/plane-src/apps/web/ce/components/common/extended-app-header.tsx b/plane-src/apps/web/ce/components/common/extended-app-header.tsx new file mode 100644 index 0000000..8149e25 --- /dev/null +++ b/plane-src/apps/web/ce/components/common/extended-app-header.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { ReactNode } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "react-router"; +// components +import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences"; + +export const ExtendedAppHeader = observer(function ExtendedAppHeader(props: { header: ReactNode }) { + const { header } = props; + // params + const { projectId, workItem } = useParams(); + // preferences + const { preferences: projectPreferences } = useProjectNavigationPreferences(); + // store hooks + const { sidebarCollapsed } = useAppTheme(); + // derived values + const shouldShowSidebarToggleButton = projectPreferences.navigationMode === "ACCORDION" || (!projectId && !workItem); + + return ( + <> + {sidebarCollapsed && shouldShowSidebarToggleButton && } +
{header}
+ + ); +}); diff --git a/plane-src/apps/web/ce/components/common/modal/global.tsx b/plane-src/apps/web/ce/components/common/modal/global.tsx new file mode 100644 index 0000000..98292c6 --- /dev/null +++ b/plane-src/apps/web/ce/components/common/modal/global.tsx @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { lazy, Suspense } from "react"; +import { observer } from "mobx-react"; + +const ProfileSettingsModal = lazy(() => + import("@/components/settings/profile/modal").then((module) => ({ + default: module.ProfileSettingsModal, + })) +); + +type TGlobalModalsProps = { + workspaceSlug: string; +}; + +/** + * GlobalModals component manages all workspace-level modals across Plane applications. + * + * This includes: + * - Profile settings modal + */ +export const GlobalModals = observer(function GlobalModals(_props: TGlobalModalsProps) { + return ( + + + + ); +}); diff --git a/plane-src/apps/web/ce/components/common/quick-actions-factory.tsx b/plane-src/apps/web/ce/components/common/quick-actions-factory.tsx new file mode 100644 index 0000000..9f94c8d --- /dev/null +++ b/plane-src/apps/web/ce/components/common/quick-actions-factory.tsx @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export { useQuickActionsFactory } from "@/components/common/quick-actions-factory"; diff --git a/plane-src/apps/web/ce/components/common/subscription/subscription-pill.tsx b/plane-src/apps/web/ce/components/common/subscription/subscription-pill.tsx new file mode 100644 index 0000000..89efebe --- /dev/null +++ b/plane-src/apps/web/ce/components/common/subscription/subscription-pill.tsx @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { IWorkspace } from "@plane/types"; + +type TProps = { + workspace?: IWorkspace; +}; + +export function SubscriptionPill(_props: TProps) { + return <>; +} diff --git a/plane-src/apps/web/ce/components/cycles/active-cycle/index.ts b/plane-src/apps/web/ce/components/cycles/active-cycle/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/plane-src/apps/web/ce/components/cycles/active-cycle/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./root"; diff --git a/plane-src/apps/web/ce/components/cycles/active-cycle/root.tsx b/plane-src/apps/web/ce/components/cycles/active-cycle/root.tsx new file mode 100644 index 0000000..ab413b8 --- /dev/null +++ b/plane-src/apps/web/ce/components/cycles/active-cycle/root.tsx @@ -0,0 +1,154 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +import { Disclosure } from "@headlessui/react"; +import { EmptyStateDetailed } from "@plane/propel/empty-state"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import type { ICycle } from "@plane/types"; +import { Row } from "@plane/ui"; +// assets +import darkActiveCycleAsset from "@/app/assets/empty-state/cycle/active-dark.webp?url"; +import lightActiveCycleAsset from "@/app/assets/empty-state/cycle/active-light.webp?url"; +// components +import { ActiveCycleStats } from "@/components/cycles/active-cycle/cycle-stats"; +import { ActiveCycleProductivity } from "@/components/cycles/active-cycle/productivity"; +import { ActiveCycleProgress } from "@/components/cycles/active-cycle/progress"; +import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details"; +import { CycleListGroupHeader } from "@/components/cycles/list/cycle-list-group-header"; +import { CyclesListItem } from "@/components/cycles/list/cycles-list-item"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import type { ActiveCycleIssueDetails } from "@/store/issue/cycle"; + +interface IActiveCycleDetails { + workspaceSlug: string; + projectId: string; + cycleId?: string; + showHeader?: boolean; +} + +type ActiveCyclesComponentProps = { + cycleId: string | null | undefined; + activeCycle: ICycle | null; + activeCycleResolvedPath: string; + workspaceSlug: string; + projectId: string; + handleFiltersUpdate: (filters: any) => void; + cycleIssueDetails?: ActiveCycleIssueDetails | { nextPageResults: boolean }; +}; + +const ActiveCyclesComponent = observer(function ActiveCyclesComponent({ + cycleId, + activeCycle, + activeCycleResolvedPath: _activeCycleResolvedPath, + workspaceSlug, + projectId, + handleFiltersUpdate, + cycleIssueDetails, +}: ActiveCyclesComponentProps) { + const { t } = useTranslation(); + + if (!cycleId || !activeCycle) { + return ( + + ); + } + + return ( +
+ + +
+ + + +
+
+
+ ); +}); + +export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: IActiveCycleDetails) { + const { workspaceSlug, projectId, cycleId: propsCycleId, showHeader = true } = props; + // theme hook + const { resolvedTheme } = useTheme(); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentProjectActiveCycleId } = useCycle(); + // derived values + const cycleId = propsCycleId ?? currentProjectActiveCycleId; + const activeCycleResolvedPath = resolvedTheme === "light" ? lightActiveCycleAsset : darkActiveCycleAsset; + // fetch cycle details + const { + handleFiltersUpdate, + cycle: activeCycle, + cycleIssueDetails, + } = useCyclesDetails({ workspaceSlug, projectId, cycleId }); + + return ( + <> + {showHeader ? ( + + {({ open }) => ( + <> + + + + + + + + )} + + ) : ( + + )} + + ); +}); diff --git a/plane-src/apps/web/ce/components/cycles/additional-actions.tsx b/plane-src/apps/web/ce/components/cycles/additional-actions.tsx new file mode 100644 index 0000000..45bc7b5 --- /dev/null +++ b/plane-src/apps/web/ce/components/cycles/additional-actions.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +type Props = { + cycleId: string; + projectId: string; +}; +export const CycleAdditionalActions = observer(function CycleAdditionalActions(_props: Props) { + return <>; +}); diff --git a/plane-src/apps/web/ce/components/cycles/analytics-sidebar/base.tsx b/plane-src/apps/web/ce/components/cycles/analytics-sidebar/base.tsx new file mode 100644 index 0000000..9467ec6 --- /dev/null +++ b/plane-src/apps/web/ce/components/cycles/analytics-sidebar/base.tsx @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Fragment } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import type { TCycleEstimateType } from "@plane/types"; +import { Loader } from "@plane/ui"; +import { getDate } from "@plane/utils"; +// components +import ProgressChart from "@/components/core/sidebar/progress-chart"; +import { validateCycleSnapshot } from "@/components/cycles/analytics-sidebar/issue-progress"; +import { EstimateTypeDropdown } from "@/components/cycles/dropdowns"; +// hooks +import { useCycle } from "@/hooks/store/use-cycle"; + +type ProgressChartProps = { + workspaceSlug: string; + projectId: string; + cycleId: string; +}; +export const SidebarChart = observer(function SidebarChart(props: ProgressChartProps) { + const { workspaceSlug, projectId, cycleId } = props; + + // hooks + const { getEstimateTypeByCycleId, getCycleById, fetchCycleDetails, fetchArchivedCycleDetails, setEstimateType } = + useCycle(); + const { t } = useTranslation(); + + // derived data + const cycleDetails = validateCycleSnapshot(getCycleById(cycleId)); + const cycleStartDate = getDate(cycleDetails?.start_date); + const cycleEndDate = getDate(cycleDetails?.end_date); + const totalEstimatePoints = cycleDetails?.total_estimate_points || 0; + const totalIssues = cycleDetails?.total_issues || 0; + const estimateType = getEstimateTypeByCycleId(cycleId); + + const chartDistributionData = + estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined; + + const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; + + if (!workspaceSlug || !projectId || !cycleId) return null; + + const isArchived = !!cycleDetails?.archived_at; + + // handlers + const onChange = async (value: TCycleEstimateType) => { + setEstimateType(cycleId, value); + if (!workspaceSlug || !projectId || !cycleId) return; + try { + if (isArchived) { + await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId); + } else { + await fetchCycleDetails(workspaceSlug, projectId, cycleId); + } + } catch (err) { + console.error(err); + setEstimateType(cycleId, estimateType); + } + }; + return ( +
+
+ +
+
+
+ {cycleStartDate && cycleEndDate && completionChartDistributionData ? ( + + + + ) : ( + + + + )} +
+
+
+ ); +}); diff --git a/plane-src/apps/web/ce/components/cycles/analytics-sidebar/index.ts b/plane-src/apps/web/ce/components/cycles/analytics-sidebar/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/plane-src/apps/web/ce/components/cycles/analytics-sidebar/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./root"; diff --git a/plane-src/apps/web/ce/components/cycles/analytics-sidebar/root.tsx b/plane-src/apps/web/ce/components/cycles/analytics-sidebar/root.tsx new file mode 100644 index 0000000..17501e0 --- /dev/null +++ b/plane-src/apps/web/ce/components/cycles/analytics-sidebar/root.tsx @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +// components +import { SidebarChart } from "./base"; + +type Props = { + workspaceSlug: string; + projectId: string; + cycleId: string; +}; + +export function SidebarChartRoot(props: Props) { + return ; +} diff --git a/plane-src/apps/web/ce/components/cycles/end-cycle/index.ts b/plane-src/apps/web/ce/components/cycles/end-cycle/index.ts new file mode 100644 index 0000000..dd65a9e --- /dev/null +++ b/plane-src/apps/web/ce/components/cycles/end-cycle/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./modal"; diff --git a/plane-src/apps/web/ce/components/cycles/end-cycle/modal.tsx b/plane-src/apps/web/ce/components/cycles/end-cycle/modal.tsx new file mode 100644 index 0000000..98c9069 --- /dev/null +++ b/plane-src/apps/web/ce/components/cycles/end-cycle/modal.tsx @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; + +interface Props { + isOpen: boolean; + handleClose: () => void; + cycleId: string; + projectId: string; + workspaceSlug: string; + transferrableIssuesCount: number; + cycleName: string; +} + +export function EndCycleModal(_props: Props) { + return <>; +} diff --git a/plane-src/apps/web/ce/components/cycles/index.ts b/plane-src/apps/web/ce/components/cycles/index.ts new file mode 100644 index 0000000..defcd50 --- /dev/null +++ b/plane-src/apps/web/ce/components/cycles/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./active-cycle"; +export * from "./analytics-sidebar"; +export * from "./additional-actions"; +export * from "./end-cycle"; diff --git a/plane-src/apps/web/ce/components/de-dupe/de-dupe-button.tsx b/plane-src/apps/web/ce/components/de-dupe/de-dupe-button.tsx new file mode 100644 index 0000000..cfeeeb8 --- /dev/null +++ b/plane-src/apps/web/ce/components/de-dupe/de-dupe-button.tsx @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +// local components + +type TDeDupeButtonRoot = { + workspaceSlug: string; + isDuplicateModalOpen: boolean; + handleOnClick: () => void; + label: string; +}; + +export function DeDupeButtonRoot(_props: TDeDupeButtonRoot) { + return <>; +} diff --git a/plane-src/apps/web/ce/components/de-dupe/duplicate-modal/index.ts b/plane-src/apps/web/ce/components/de-dupe/duplicate-modal/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/plane-src/apps/web/ce/components/de-dupe/duplicate-modal/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./root"; diff --git a/plane-src/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx b/plane-src/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx new file mode 100644 index 0000000..4afbdec --- /dev/null +++ b/plane-src/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// types +import type { TDeDupeIssue } from "@plane/types"; + +type TDuplicateModalRootProps = { + workspaceSlug: string; + issues: TDeDupeIssue[]; + handleDuplicateIssueModal: (value: boolean) => void; +}; + +export function DuplicateModalRoot(_props: TDuplicateModalRootProps) { + return <>; +} diff --git a/plane-src/apps/web/ce/components/de-dupe/duplicate-popover/index.ts b/plane-src/apps/web/ce/components/de-dupe/duplicate-popover/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/plane-src/apps/web/ce/components/de-dupe/duplicate-popover/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./root"; diff --git a/plane-src/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx b/plane-src/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx new file mode 100644 index 0000000..146d6b4 --- /dev/null +++ b/plane-src/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +import { observer } from "mobx-react"; +// types +import type { TDeDupeIssue } from "@plane/types"; +import type { TIssueOperations } from "@/components/issues/issue-detail"; + +type TDeDupeIssuePopoverRootProps = { + workspaceSlug: string; + projectId: string; + rootIssueId: string; + issues: TDeDupeIssue[]; + issueOperations: TIssueOperations; + disabled?: boolean; + renderDeDupeActionModals?: boolean; + isIntakeIssue?: boolean; +}; + +export const DeDupeIssuePopoverRoot = observer(function DeDupeIssuePopoverRoot(props: TDeDupeIssuePopoverRootProps) { + const {} = props; + return <>; +}); diff --git a/plane-src/apps/web/ce/components/de-dupe/issue-block/button-label.tsx b/plane-src/apps/web/ce/components/de-dupe/issue-block/button-label.tsx new file mode 100644 index 0000000..ae89c77 --- /dev/null +++ b/plane-src/apps/web/ce/components/de-dupe/issue-block/button-label.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +type TDeDupeIssueButtonLabelProps = { + isOpen: boolean; + buttonLabel: string; +}; + +export function DeDupeIssueButtonLabel(_props: TDeDupeIssueButtonLabelProps) { + return <>; +} diff --git a/plane-src/apps/web/ce/components/desktop/helper.ts b/plane-src/apps/web/ce/components/desktop/helper.ts new file mode 100644 index 0000000..cae7444 --- /dev/null +++ b/plane-src/apps/web/ce/components/desktop/helper.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export const isSidebarToggleVisible = () => true; diff --git a/plane-src/apps/web/ce/components/desktop/index.ts b/plane-src/apps/web/ce/components/desktop/index.ts new file mode 100644 index 0000000..8b7f587 --- /dev/null +++ b/plane-src/apps/web/ce/components/desktop/index.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./helper"; +export * from "./sidebar-workspace-menu"; diff --git a/plane-src/apps/web/ce/components/desktop/sidebar-workspace-menu.tsx b/plane-src/apps/web/ce/components/desktop/sidebar-workspace-menu.tsx new file mode 100644 index 0000000..9dadb9e --- /dev/null +++ b/plane-src/apps/web/ce/components/desktop/sidebar-workspace-menu.tsx @@ -0,0 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export function DesktopSidebarWorkspaceMenu() { + return null; +} diff --git a/plane-src/apps/web/ce/components/editor/embeds/mentions/index.ts b/plane-src/apps/web/ce/components/editor/embeds/mentions/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/plane-src/apps/web/ce/components/editor/embeds/mentions/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./root"; diff --git a/plane-src/apps/web/ce/components/editor/embeds/mentions/root.tsx b/plane-src/apps/web/ce/components/editor/embeds/mentions/root.tsx new file mode 100644 index 0000000..21802e0 --- /dev/null +++ b/plane-src/apps/web/ce/components/editor/embeds/mentions/root.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// plane imports +import type { TCallbackMentionComponentProps } from "@plane/editor"; + +export type TEditorMentionComponentProps = TCallbackMentionComponentProps; + +export function EditorAdditionalMentionsRoot(_props: TEditorMentionComponentProps) { + return null; +} diff --git a/plane-src/apps/web/ce/components/epics/epic-modal/index.ts b/plane-src/apps/web/ce/components/epics/epic-modal/index.ts new file mode 100644 index 0000000..dd65a9e --- /dev/null +++ b/plane-src/apps/web/ce/components/epics/epic-modal/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./modal"; diff --git a/plane-src/apps/web/ce/components/epics/epic-modal/modal.tsx b/plane-src/apps/web/ce/components/epics/epic-modal/modal.tsx new file mode 100644 index 0000000..90d7c9f --- /dev/null +++ b/plane-src/apps/web/ce/components/epics/epic-modal/modal.tsx @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +import type { TIssue } from "@plane/types"; + +export interface EpicModalProps { + data?: Partial; + isOpen: boolean; + onClose: () => void; + beforeFormSubmit?: () => Promise; + onSubmit?: (res: TIssue) => Promise; + fetchIssueDetails?: boolean; + primaryButtonText?: { + default: string; + loading: string; + }; + isProjectSelectionDisabled?: boolean; +} + +export function CreateUpdateEpicModal(_props: EpicModalProps) { + return <>; +} diff --git a/plane-src/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx b/plane-src/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx new file mode 100644 index 0000000..a289ca0 --- /dev/null +++ b/plane-src/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; +import { TrashIcon } from "@plane/propel/icons"; + +type TEstimateListItem = { + estimateId: string; + isAdmin: boolean; + isEstimateEnabled: boolean; + isEditable: boolean; + onEditClick?: (estimateId: string) => void; + onDeleteClick?: (estimateId: string) => void; +}; + +export const EstimateListItemButtons = observer(function EstimateListItemButtons(props: TEstimateListItem) { + const { estimateId, isAdmin, isEditable, onDeleteClick } = props; + + if (!isAdmin || !isEditable) return <>; + return ( +
+ +
+ ); +}); diff --git a/plane-src/apps/web/ce/components/estimates/helper.tsx b/plane-src/apps/web/ce/components/estimates/helper.tsx new file mode 100644 index 0000000..12c91f6 --- /dev/null +++ b/plane-src/apps/web/ce/components/estimates/helper.tsx @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { TEstimateSystemKeys } from "@plane/types"; +import { EEstimateSystem } from "@plane/types"; + +export const isEstimateSystemEnabled = (key: TEstimateSystemKeys) => { + switch (key) { + case EEstimateSystem.POINTS: + return true; + case EEstimateSystem.CATEGORIES: + return true; + default: + return false; + } +}; diff --git a/plane-src/apps/web/ce/components/estimates/index.ts b/plane-src/apps/web/ce/components/estimates/index.ts new file mode 100644 index 0000000..7fd8f88 --- /dev/null +++ b/plane-src/apps/web/ce/components/estimates/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./estimate-list-item-buttons"; +export * from "./update"; +export * from "./points"; +export * from "./helper"; diff --git a/plane-src/apps/web/ce/components/estimates/inputs/index.ts b/plane-src/apps/web/ce/components/estimates/inputs/index.ts new file mode 100644 index 0000000..778cadf --- /dev/null +++ b/plane-src/apps/web/ce/components/estimates/inputs/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./time-input"; diff --git a/plane-src/apps/web/ce/components/estimates/inputs/time-input.tsx b/plane-src/apps/web/ce/components/estimates/inputs/time-input.tsx new file mode 100644 index 0000000..6eadf41 --- /dev/null +++ b/plane-src/apps/web/ce/components/estimates/inputs/time-input.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export type TEstimateTimeInputProps = { + value?: number; + handleEstimateInputValue: (value: string) => void; +}; + +export function EstimateTimeInput(_props: TEstimateTimeInputProps) { + return <>; +} diff --git a/plane-src/apps/web/ce/components/estimates/points/delete.tsx b/plane-src/apps/web/ce/components/estimates/points/delete.tsx new file mode 100644 index 0000000..c64ee12 --- /dev/null +++ b/plane-src/apps/web/ce/components/estimates/points/delete.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; + +export type TEstimatePointDelete = { + workspaceSlug: string; + projectId: string; + estimateId: string; + estimatePointId: string; + estimatePoints: TEstimatePointsObject[]; + callback: () => void; + estimatePointError?: TEstimateTypeErrorObject | undefined; + handleEstimatePointError?: (newValue: string, message: string | undefined, mode?: "add" | "delete") => void; + estimateSystem: TEstimateSystemKeys; +}; + +export function EstimatePointDelete(_props: TEstimatePointDelete) { + return <>; +} diff --git a/plane-src/apps/web/ce/components/estimates/points/index.ts b/plane-src/apps/web/ce/components/estimates/points/index.ts new file mode 100644 index 0000000..c83c460 --- /dev/null +++ b/plane-src/apps/web/ce/components/estimates/points/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./delete"; diff --git a/plane-src/apps/web/ce/components/estimates/update/index.ts b/plane-src/apps/web/ce/components/estimates/update/index.ts new file mode 100644 index 0000000..dd65a9e --- /dev/null +++ b/plane-src/apps/web/ce/components/estimates/update/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./modal"; diff --git a/plane-src/apps/web/ce/components/estimates/update/modal.tsx b/plane-src/apps/web/ce/components/estimates/update/modal.tsx new file mode 100644 index 0000000..e0600f3 --- /dev/null +++ b/plane-src/apps/web/ce/components/estimates/update/modal.tsx @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; + +type TUpdateEstimateModal = { + workspaceSlug: string; + projectId: string; + estimateId: string | undefined; + isOpen: boolean; + handleClose: () => void; +}; + +export const UpdateEstimateModal = observer(function UpdateEstimateModal(_props: TUpdateEstimateModal) { + return <>; +}); diff --git a/plane-src/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx b/plane-src/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx new file mode 100644 index 0000000..4d6da66 --- /dev/null +++ b/plane-src/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// components +import type { IBlockUpdateData, IGanttBlock } from "@plane/types"; +import RenderIfVisible from "@/components/core/render-if-visible-HOC"; +// hooks +import { BlockRow } from "@/components/gantt-chart/blocks/block-row"; +import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; +// types + +export type GanttChartBlocksProps = { + blockIds: string[]; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + handleScrollToBlock: (block: IGanttBlock) => void; + enableAddBlock: boolean | ((blockId: string) => boolean); + showAllBlocks: boolean; + selectionHelpers: TSelectionHelper; + ganttContainerRef: React.RefObject; +}; + +export function GanttChartRowList(props: GanttChartBlocksProps) { + const { + blockIds, + blockUpdateHandler, + handleScrollToBlock, + enableAddBlock, + showAllBlocks, + selectionHelpers, + ganttContainerRef, + } = props; + + return ( +
+ {blockIds?.map((blockId) => ( + <> + } + shouldRecordHeights={false} + > + + + + ))} +
+ ); +} diff --git a/plane-src/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx b/plane-src/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx new file mode 100644 index 0000000..fcb9d09 --- /dev/null +++ b/plane-src/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// +import type { IBlockUpdateDependencyData } from "@plane/types"; +import { GanttChartBlock } from "@/components/gantt-chart/blocks/block"; + +export type GanttChartBlocksProps = { + blockIds: string[]; + blockToRender: (data: any) => React.ReactNode; + enableBlockLeftResize: boolean | ((blockId: string) => boolean); + enableBlockRightResize: boolean | ((blockId: string) => boolean); + enableBlockMove: boolean | ((blockId: string) => boolean); + ganttContainerRef: React.RefObject; + showAllBlocks: boolean; + updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise; + enableDependency: boolean | ((blockId: string) => boolean); +}; + +export function GanttChartBlocksList(props: GanttChartBlocksProps) { + const { + blockIds, + blockToRender, + enableBlockLeftResize, + enableBlockRightResize, + enableBlockMove, + ganttContainerRef, + showAllBlocks, + updateBlockDates, + enableDependency, + } = props; + + return ( + <> + {blockIds?.map((blockId) => ( + + ))} + + ); +} diff --git a/plane-src/apps/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts b/plane-src/apps/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts new file mode 100644 index 0000000..47ac85d --- /dev/null +++ b/plane-src/apps/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./left-draggable"; +export * from "./right-draggable"; diff --git a/plane-src/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx b/plane-src/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx new file mode 100644 index 0000000..34a8aac --- /dev/null +++ b/plane-src/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { RefObject } from "react"; +import type { IGanttBlock } from "@plane/types"; + +type LeftDependencyDraggableProps = { + block: IGanttBlock; + ganttContainerRef: RefObject; +}; + +export function LeftDependencyDraggable(_props: LeftDependencyDraggableProps) { + return <>; +} diff --git a/plane-src/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx b/plane-src/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx new file mode 100644 index 0000000..d6badd0 --- /dev/null +++ b/plane-src/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { RefObject } from "react"; +import type { IGanttBlock } from "@plane/types"; + +type RightDependencyDraggableProps = { + block: IGanttBlock; + ganttContainerRef: RefObject; +}; +export function RightDependencyDraggable(_props: RightDependencyDraggableProps) { + return <>; +} diff --git a/plane-src/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx b/plane-src/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx new file mode 100644 index 0000000..6332a71 --- /dev/null +++ b/plane-src/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +type Props = { + isEpic?: boolean; +}; +export function TimelineDependencyPaths(_props: Props) { + return <>; +} diff --git a/plane-src/apps/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx b/plane-src/apps/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx new file mode 100644 index 0000000..0f73549 --- /dev/null +++ b/plane-src/apps/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx @@ -0,0 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export function TimelineDraggablePath() { + return <>; +} diff --git a/plane-src/apps/web/ce/components/gantt-chart/dependency/index.ts b/plane-src/apps/web/ce/components/gantt-chart/dependency/index.ts new file mode 100644 index 0000000..d92fa43 --- /dev/null +++ b/plane-src/apps/web/ce/components/gantt-chart/dependency/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./blockDraggables"; +export * from "./dependency-paths"; +export * from "./draggable-dependency-path"; diff --git a/plane-src/apps/web/ce/components/gantt-chart/index.ts b/plane-src/apps/web/ce/components/gantt-chart/index.ts new file mode 100644 index 0000000..cebec20 --- /dev/null +++ b/plane-src/apps/web/ce/components/gantt-chart/index.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./dependency"; +export * from "./layers"; diff --git a/plane-src/apps/web/ce/components/gantt-chart/layers/additional-layers.tsx b/plane-src/apps/web/ce/components/gantt-chart/layers/additional-layers.tsx new file mode 100644 index 0000000..0a5ced2 --- /dev/null +++ b/plane-src/apps/web/ce/components/gantt-chart/layers/additional-layers.tsx @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { FC } from "react"; + +type Props = { + itemsContainerWidth: number; + blockCount: number; +}; + +export const GanttAdditionalLayers: FC = () => null; diff --git a/plane-src/apps/web/ce/components/gantt-chart/layers/index.ts b/plane-src/apps/web/ce/components/gantt-chart/layers/index.ts new file mode 100644 index 0000000..84012f8 --- /dev/null +++ b/plane-src/apps/web/ce/components/gantt-chart/layers/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export { GanttAdditionalLayers } from "./additional-layers"; diff --git a/plane-src/apps/web/ce/components/global/index.ts b/plane-src/apps/web/ce/components/global/index.ts new file mode 100644 index 0000000..1d62fc2 --- /dev/null +++ b/plane-src/apps/web/ce/components/global/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./version-number"; diff --git a/plane-src/apps/web/ce/components/global/product-updates/changelog.tsx b/plane-src/apps/web/ce/components/global/product-updates/changelog.tsx new file mode 100644 index 0000000..06de236 --- /dev/null +++ b/plane-src/apps/web/ce/components/global/product-updates/changelog.tsx @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState, useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +// hooks +import { Loader } from "@plane/ui"; +import { ProductUpdatesFallback } from "@/components/global/product-updates/fallback"; +import { useInstance } from "@/hooks/store/use-instance"; + +export const ProductUpdatesChangelog = observer(function ProductUpdatesChangelog() { + // refs + const isLoadingRef = useRef(true); + // states + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + // store hooks + const { config } = useInstance(); + // derived values + const changeLogUrl = config?.instance_changelog_url; + const shouldShowFallback = !changeLogUrl || changeLogUrl === "" || hasError; + + // timeout fallback - if iframe doesn't load within 15 seconds, show error + useEffect(() => { + if (!changeLogUrl || changeLogUrl === "") { + setIsLoading(false); + isLoadingRef.current = false; + return; + } + + setIsLoading(true); + setHasError(false); + isLoadingRef.current = true; + + const timeoutId = setTimeout(() => { + if (isLoadingRef.current) { + setHasError(true); + setIsLoading(false); + isLoadingRef.current = false; + } + }, 15000); // 15 second timeout + + return () => { + clearTimeout(timeoutId); + }; + }, [changeLogUrl]); + + const handleIframeLoad = () => { + setTimeout(() => { + isLoadingRef.current = false; + setIsLoading(false); + }, 1000); + }; + + const handleIframeError = () => { + isLoadingRef.current = false; + setHasError(true); + setIsLoading(false); + }; + + // Show fallback if URL is missing, empty, or iframe failed to load + if (shouldShowFallback) { + return ( + + ); + } + + return ( +
+ {isLoading && ( + + + + )} +