commit 23059a1de38eb51af6cfc66527f09d5e7466d469 Author: upstream-mirror Date: Tue Sep 15 15:14:58 2026 +0500 snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15) diff --git a/.claude/skills/branch-name/SKILL.md b/.claude/skills/branch-name/SKILL.md new file mode 100644 index 0000000..a6fc6e5 --- /dev/null +++ b/.claude/skills/branch-name/SKILL.md @@ -0,0 +1,67 @@ +--- +name: branch-name +description: Use when starting a new branch or renaming an existing one — produces a branch name in the format `/-` that's compatible with the create-pr skill's work item ID extraction. +user_invocable: true +--- + +# Branch Naming + +Create branch names that follow the convention `/-`, where the work item ID can be cleanly extracted later (e.g., by the create-pr skill). + +## Format + +``` +/- +``` + +- All lowercase, hyphen-separated +- Work item ID stays in its original form but lowercased (e.g., `SILO-1146` → `silo-1146`) +- Short description is 2–5 words in kebab-case, focused on the _what_, not the _how_ + +## Workflow + +1. **Determine the type** based on the work being done: + - `feat` — new functionality + - `fix` — bug fix + - `chore` — tooling, deps, config, non-user-facing housekeeping + - `refactor` — restructuring without behavior change + - `docs` — documentation only + - `perf` — performance improvement + +2. **Determine the work item ID**: + - If the user gives one, use it + - If they reference a Plane work item (e.g., a URL or title), extract the ID + - If none exists, ask the user — don't invent one + +3. **Write the short description**: + - 2–5 words in kebab-case + - Describe the outcome, not the implementation (`add-app-tile-visibility`, not `update-tile-component`) + - Skip filler words (`the`, `a`, `for`) + +4. **Assemble and create the branch**: + +``` + git checkout -b /- +``` + +5. **Return the branch name** to the user. + +## Examples + +``` +fix/silo-1146-relative-config-urls +feat/web-1234-app-tile-visibility +chore/web-2201-bump-eslint +refactor/silo-980-extract-auth-middleware +docs/web-1500-pr-template-update +perf/silo-1310-cache-workspace-lookup +``` + +## Common Mistakes + +- Putting the work item ID at the end instead of after the type (breaks extraction) +- Using underscores or camelCase instead of hyphens +- Uppercasing the work item ID inside the branch name (it should be lowercase here, uppercased only when used as the PR title prefix) +- Writing a long, narrative description — keep it scannable +- Omitting the work item ID when one exists in Plane +- Using a type that won't match the eventual PR type (pick the type you'd use in the PR title) diff --git a/.claude/skills/create-pull-request/SKILL.md b/.claude/skills/create-pull-request/SKILL.md new file mode 100644 index 0000000..e6fa2d8 --- /dev/null +++ b/.claude/skills/create-pull-request/SKILL.md @@ -0,0 +1,65 @@ +--- +name: create-pull-request +description: Use when creating a pull request for the current branch — gathers branch context, generates a PR description following the repo's pull_request_template.md, and creates the PR with a Plane work item ID prefix in the title. +user_invocable: true +--- + +# Create PR + +Create a pull request using the repo's PR template, a Plane work item ID as the title prefix, and a fully filled-out description based on the actual diff. + +## Workflow + +1. **Determine the base branch**: Default to `preview` unless the user specifies otherwise. + +2. **Gather context** (in parallel): + - `git status -s` — check for uncommitted changes + - `git diff ...HEAD --stat` — files changed + - `git log ...HEAD --oneline` — all commits on the branch + - `git diff ...HEAD --no-color` — full diff for understanding changes (if very large, focus on the most important files first) + - `git rev-parse --abbrev-ref --symbolic-full-name @{u}` — check if branch tracks a remote + - Read `.github/pull_request_template.md` from the repo root + +3. **Determine work item ID**: + - Extract from branch name if it contains an identifier (e.g., `chore/silo-1146-foo` → `SILO-1146`, `feat/web-1234-x` → `WEB-1234`) + - If not found in branch name, ask the user + +4. **Draft the PR** using the template from step 2: + + **Title**: `[WORK-ITEM-ID] : ` (under 70 chars) + - Type reflects the change: `fix`, `feat`, `chore`, `refactor`, `docs`, `perf`, etc. + + **Body**: Fill in every section from the PR template based on the actual diff: + - **Description** — Clear, concise summary of what the PR does and why. Focus on the "what" and "why", not line-by-line changes. Mention important implementation decisions. + - **Type of Change** — Check the appropriate box(es): Bug fix, Feature, Improvement, Code refactoring, Performance improvements, Documentation update. + - **Screenshots and Media** — Leave a placeholder: `` + - **Test Scenarios** — Suggest concrete scenarios grounded in the actual changes (e.g., "Navigate to project settings and verify the new toggle works"), not generic ones. + - **References** — Include the work item ID, any linked issues the user mentions, and any Sentry issue links/IDs (e.g., `SENTRY-ABC123` or Sentry URLs) referenced earlier in the conversation. + + Append a Claude Code session line at the bottom of the body. + +5. **Push and create** (in parallel where possible): + - Push branch with `-u` if no upstream is set + - Create PR via `gh pr create` using a HEREDOC for the body + +6. **Return the PR URL** to the user. + +## Example Title + +``` +[SILO-1146] fix: allow relative URLs for configuration_url and improve app tile visibility +``` + +## Guidelines + +- Keep the description concise but informative +- Use bullet points when listing multiple changes +- Focus on user-facing impact, not implementation details +- Don't fabricate test scenarios that aren't relevant to the actual changes + +## Common Mistakes + +- Summarizing only the latest commit instead of all commits on the branch +- Forgetting to check for an upstream before pushing +- Using a work item ID format that doesn't match the branch convention +- Wrapping the PR body in a code fence when passing it to `gh pr create` diff --git a/.claude/skills/react-doctor/SKILL.md b/.claude/skills/react-doctor/SKILL.md new file mode 100644 index 0000000..3afcd67 --- /dev/null +++ b/.claude/skills/react-doctor/SKILL.md @@ -0,0 +1,50 @@ +--- +name: react-doctor +description: Use when finishing a feature, fixing a bug, before committing React code, or when the user types `/doctor`, asks to scan, triage, or clean up React diagnostics. Covers lint, accessibility, bundle size, architecture. Includes a regression check and a full local-triage workflow that fetches the canonical playbook. +version: "1.1.0" +--- + +# React Doctor + +Scans React codebases for security, performance, correctness, and architecture issues. Outputs a 0–100 health score. + +## After making React code changes: + +Run `npx react-doctor@latest --verbose --diff` and check the score did not regress. + +If the score dropped, fix the regressions before committing. + +## For general cleanup or code improvement: + +Run `npx react-doctor@latest --verbose` (without `--diff`) to scan the full codebase. Fix issues by severity — errors first, then warnings. + +## /doctor — full local triage workflow + +When the user types `/doctor`, says "run react doctor", or asks for a full triage / cleanup pass (not just a regression check), fetch the canonical local-triage playbook and follow every step in it: + +```bash +curl --fail --silent --show-error \ + --header 'Cache-Control: no-cache' \ + https://www.react.doctor/prompts/react-doctor-agent.md +``` + +The playbook is the single source of truth — a scan → filter → triage → fix → validate loop that edits the working tree directly (never commits, never opens PRs). Updating the prompt at its source updates every agent on its next fetch — no skill reinstall needed. + +Pair it with the matching per-rule prompts at `https://www.react.doctor/prompts/rules//.md` (fetched on demand inside the playbook) so each fix uses the canonical, reviewer-tested recipe. + +## Configuring or explaining rules + +When the user wants to understand a rule, disagrees with one, or wants to disable / tune which rules run (not fix code), use the `doctor-explain` skill (alias `/doctor-config`). Start with `npx react-doctor@latest rules explain `, then apply the narrowest control via `npx react-doctor@latest rules disable|set|category|ignore-tag …`, which edits your `doctor.config.*` (or `package.json#reactDoctor`). + +## Command + +```bash +npx react-doctor@latest --verbose --diff +``` + +| Flag | Purpose | +| ----------- | --------------------------------------------- | +| `.` | Scan current directory | +| `--verbose` | Show affected files and line numbers per rule | +| `--diff` | Only scan changed files vs base branch | +| `--score` | Output only the numeric score | diff --git a/.claude/skills/release-notes/SKILL.md b/.claude/skills/release-notes/SKILL.md new file mode 100644 index 0000000..9db8a12 --- /dev/null +++ b/.claude/skills/release-notes/SKILL.md @@ -0,0 +1,200 @@ +--- +name: release-notes +description: "Generate release notes for a Plane release PR in either `makeplane/plane-cloud` (date-based versioning, e.g. `release: vYY.MM.DD-N`) or `makeplane/plane-ee` (semver, e.g. `release: vX.Y.Z`). Reads PR commits, filters out noise, categorizes by conventional-commit type, optionally enriches via Plane MCP, and writes the result as the PR description in the GitHub Releases format." +user_invocable: true +--- + +# Release Notes Generator + +Generate structured release notes from a Plane release PR by parsing its commit list, then update the PR description. Output matches the format used on `github.com/makeplane/plane/releases` (e.g. [v1.2.0](https://github.com/makeplane/plane/releases/tag/v1.2.0)). Works for both `makeplane/plane-cloud` and `makeplane/plane-ee`. + +## Repo-specific versioning + +Plane uses **different version schemes** across its two release repos. Detect which repo the PR belongs to and use the matching scheme when communicating about the release — the version itself does **not** appear in the release notes body (GitHub's release tag carries it). + +| Repo | Version scheme | Example PR title | Source branch | Target branch | +| ----------------------- | -------------- | ---------------------- | ------------- | -------------------- | +| `makeplane/plane-cloud` | Date-based | `release: v26.04.13-1` | `uat` | `master` | +| `makeplane/plane-ee` | Semver | `release: v1.12.0` | `uat` | `master` / `preview` | + +- **plane-cloud** ships daily — version is `vYY.MM.DD-N` where `N` is the counter for that date's release. +- **plane-ee** ships on a versioned cadence — version is `vX.Y.Z` (major.minor.patch) following semver. +- Detect the repo with `gh pr view --json headRepository,baseRepository` or from the URL the user shared. + +## When to Use + +- User links/mentions a Plane release PR (e.g. `release: v26.04.13-1` for cloud or `release: v1.12.0` for EE) and asks for release notes +- User asks to "create release notes" / "update PR description" for a PR in `makeplane/plane-cloud` or `makeplane/plane-ee` +- The branch is named `uat` or `release/x.y.z` and the base is `master` or `preview` + +## Steps + +### 1. Fetch commits + +```bash +gh pr view --json title,body,baseRefName,headRefName,commits \ + --jq '.commits[] | .messageHeadline + "\n---BODY---\n" + .messageBody + "\n===END==="' +``` + +For a quick scan first: + +```bash +gh pr view --json commits \ + --jq '.commits[] | {oid: .oid[0:10], message: .messageHeadline}' +``` + +### 2. Filter out noise + +**Always exclude** these commits — mechanical, not user-facing: + +| Pattern | Reason | +| -------------------------------------------- | ------------------------------------- | +| `Sync: Enterprise Changes #NNNN` | Cross-repo sync, no functional change | +| `fix: merge conflicts` | Merge artifact | +| `Merge branch '...' of github.com:...` | Merge artifact | +| `Revert "..."` (when immediately re-applied) | Internal churn | + +### 3. Identify work item IDs (for research only) + +Most meaningful commits begin with a Plane work item identifier in brackets: + +- `[WEB-XXXX]` — web/frontend product items +- `[SILO-XXXX]` — Silo (integrations: Slack, GitHub, GitLab, Jira/Linear) +- `[MOBILE-XXXX]`, `[API-XXXX]`, etc. + +**Do not include these IDs in the release notes.** The GitHub Releases format is end-user-facing — IDs are only useful as a lookup key for fetching context in step 4. + +### 4. (Optional) Enrich via Plane MCP + +For larger features where the commit headline is terse, fetch the work item to write a richer paragraph: + +``` +mcp__plane__retrieve_work_item_by_identifier(project_identifier="WEB", issue_identifier=6874) +``` + +Use the returned `name` and `description_stripped` to flesh out the prose. Skip for routine fixes — commit body is usually enough. Don't enrich every item (slow + descriptions are often empty). + +### 5. Categorize commits + +Map each surviving commit into one of four sections: + +| Commit signal | Section | +| --------------------------------------------------------------------------------------------------------------------- | --------------- | +| `feat:` that introduces a brand-new screen, flow, or capability | ✨ Features | +| `feat:` that improves an existing feature, plus most `refactor:` and behavioural `chore:` items that are user-visible | ⬆️ Enhancements | +| `fix:`, `fix(scope):` | 🐞 Bug fixes | +| CVE upgrades, dependency bumps that close a vulnerability, security hardening | 🛡️ Security | + +**Drop entirely** (do not surface to users): pure infra `chore:`, dependabot bumps with no CVE, internal refactors with no behavioural impact, test-only changes, doc-only changes. + +### 6. Format + +Output follows the GitHub Releases convention — `###` for section headers, with two spaces between the emoji and the label for ✨ / ⬆️ / 🐞 (matches `v1.2.0`). + +```markdown +### ✨ Features + +#### **Short Feature Name in Title Case** + +A 1–3 sentence paragraph describing what the user gets, why it matters, and any notable behaviour. Write in product-marketing voice, not commit-message voice. + +- Optional nested bullets for sub-capabilities or callouts +- Keep them user-facing — what the user can now do + +#### **Second Major Feature** + +Another descriptive paragraph. Each major feature gets its own `####` subsection. + +### ⬆️ Enhancements + +- One-line description of an improvement to an existing capability +- Another improvement, written as a clean sentence (no commit prefix, no ticket ID) + +### 🐞 Bug fixes + +- Plain-English description of what was broken and is now fixed +- Another bug fix + +### 🛡️ Security + +- Upgraded to to mitigate [CVE-XXXX-NNNNN](https://link-to-advisory). Brief impact note. +- Other security-relevant change +``` + +Rules: + +- Section headers use `###` (three hashes), then emoji + **two spaces** + label — exactly as in the published v1.2.0 release. Exception: 🛡️ Security uses a single space (matches v1.2.0). +- Features use `####` (four hashes) and the feature name is **bolded** inside the heading: `#### **Feature Name**`. +- Each feature gets a real paragraph, not a bullet — written for end users, not engineers. +- Enhancements, Bug fixes, and Security are simple bullets. No nested asterisks, no ticket IDs, no PR numbers. +- **Do not include work item IDs (`[WEB-XXXX]`) or PR numbers (`(#NNNN)`)** in any section — this format is user-facing. +- **Do not add a `# Release vX.Y.Z` heading.** The GitHub release tag carries the version; the body starts directly with the first `### ✨ Features` section. +- **Do not insert images.** The user adds screenshots manually after the notes are drafted. Leave space for them only if the user asks. +- Drop empty sections entirely. +- Blank line between section header and first bullet/feature, and between sections. + +### 7. Update the PR description + +```bash +gh pr edit --body "$(cat <<'EOF' + +EOF +)" +``` + +Always use a HEREDOC with single-quoted `'EOF'` so backticks/dollars in the notes are preserved. + +## Quick Reference: end-to-end + +```bash +PR=2498 +gh pr view $PR --json commits --jq '.commits[] | .messageHeadline + "\n---\n" + .messageBody + "\n==="' > /tmp/commits.txt +# read /tmp/commits.txt, filter, categorize into the four sections, draft notes +gh pr edit $PR --body "$(cat <<'EOF' +### ✨ Features + +#### **...** + +... + +### ⬆️ Enhancements + +- ... + +### 🐞 Bug fixes + +- ... + +### 🛡️ Security + +- ... +EOF +)" +``` + +## Reference example + +The canonical target format is [v1.2.0](https://github.com/makeplane/plane/releases/tag/v1.2.0) on `makeplane/plane`. When in doubt about heading levels, spacing, bolding, or paragraph voice, match that page exactly (minus images). + +## Common Mistakes + +- **Including work item IDs in bullets** — the GitHub Releases format is user-facing; `[WEB-XXXX]` belongs in internal research, not the output. +- **Adding a `# Release vX.Y.Z` heading** — GitHub's release tag is the version. The body starts with `### ✨ Features`. +- **Copy-pasting commit subjects verbatim** — rewrite into product-marketing English. "fix: peek overview reload on parent add" → "Fixed peek overview reloading on adding a parent". +- **Bulleting features instead of writing paragraphs** — major features get `#### **Name**` plus a real paragraph; only enhancements/bugs/security use bullets. +- **Including `Sync: Enterprise Changes` commits** — these are sync PRs, never user-visible. +- **Including `fix: merge conflicts`** — merge artifact, no functional content. +- **Inserting images** — leave images for the user; they add screenshots manually. +- **Using `--body` without HEREDOC** — backticks/dollar signs get shell-interpreted and corrupt the notes. +- **Editing the PR title** — release PR titles are version markers; only edit the body. +- **Adding a Chores section** — the GitHub Releases format has no Chores section; user-invisible chores are dropped entirely. + +## Plane-Specific Conventions + +- Release PRs go from `uat` → `master` (or `preview`). +- PR title format: + - `plane-cloud`: `release: vYY.MM.DD-N` where N is the daily release counter for that date. + - `plane-ee`: `release: vX.Y.Z` semver (major.minor.patch). +- Commits coming from feature branches always carry a work item ID; commits without one are usually infra/chores and almost always dropped from notes. +- `Sync: Enterprise Changes #NNNN` are automated cross-repo syncs and are _always_ skipped. +- CVE-related upgrades (NextJS, React, Django, nginx, etc.) belong under 🛡️ Security with a link to the advisory and a one-line impact note. diff --git a/.claude/skills/translate/SKILL.md b/.claude/skills/translate/SKILL.md new file mode 100644 index 0000000..ce83929 --- /dev/null +++ b/.claude/skills/translate/SKILL.md @@ -0,0 +1,608 @@ +--- +name: translate +description: Translate or update keys in packages/i18n/src/locales. Use whenever adding, changing, or reviewing strings across any target locale — enforces do-not-translate terminology, CLDR plural forms, placeholder/tag preservation, per-locale punctuation/register, and the AI-translation review workflow. Required reading before touching any *.json under src/locales. +user_invocable: true +--- + + + +# Translate (Plane i18n) + +Single source of truth for turning English UI strings into every target locale under `packages/i18n/src/locales/`. Follow this skill exactly — every mistake here ships to every user in that language. + +The locale list grows over time. This skill is intentionally generic: rules apply to any locale present now or added later. When you add a locale not explicitly named below, see **Adding a locale not documented here** at the bottom. + +Sources distilled from: Microsoft Localization Style Guides, Mozilla L10n, Unicode CLDR plural rules, W3C i18n, Google developer style guide, Apple HIG, GitHub Primer, MQM error typology, Lokalise/Crowdin/Phrase best-practice docs, and Microsoft's AI/LLM-for-translation guidance. + +**Source of truth**: `packages/i18n/src/locales/en/.json`. Every other locale mirrors English key-for-key. The `sync-check.ts` script catches missing / stale / collision keys; it does **not** catch value-quality mistakes. That is what this skill is for. + +## When to Use + +- Adding a new key to any `packages/i18n/src/locales/en/*.json` +- Renaming or rewording an English value (every target is now stale) +- Adding a new language (copy from `en/`, then translate each file) +- Syncing after `pnpm --filter @plane/i18n run sync:check` reports drift +- Reviewing any PR that touches `packages/i18n/src/locales/` + +Skip only for trivial English-only typo fixes that don't change meaning or length meaningfully. + +## The Two Iron Rules + +1. **Trademarks, brand marks, plan tier names, third-party product names, acronyms, and code tokens are never translated.** Plane's brand marks (Plane, Plane AI, Power K, PQL, Active Cycles, Sticky/Stickies, Intake), plan tiers (Pro, Business, Enterprise), third-party products (GitHub, Slack, Notion, etc.), and acronyms (API, OAuth, etc.) stay Latin in every locale. +2. **CLDR plural categories are mandatory.** Every target locale must include **every** plural keyword the language requires. Missing a form renders the wrong word at runtime and passes `sync-check` silently. + +Common feature nouns — Cycle, Module, Epic, Page — **are translated** into the target language using the canonical glossary further down. They are not brand marks; they are everyday words that belong in the user's language. + +The rest of this skill explains how to execute on those rules. + +## Do-Not-Translate (DNT) Glossary + +For each term: the source, the required rendering per script group, and **forbidden renderings** that have appeared historically and must be reverted when seen. + +### Plane brand & features + +| Source term | Latin locales (fr, es, it, de, pt-BR, pl, cs, sk, ro, tr-TR, vi-VN, id) | ja (katakana-default) | ko (Hangul-default) | zh-CN / zh-TW | ru | ua | **Forbidden** (never produce) | +| ---------------------------------- | ----------------------------------------------------------------------- | --------------------- | ------------------- | --------------------------------------------- | ----------------- | ----------------- | --------------------------------------------------------------------------------- | +| **Plane** | Plane | Plane | Plane | Plane | Plane | Plane | 飛行機, 飞机, 비행기, Самолёт, Літак, Avion, Avião, Aereo, Flugzeug | +| **Plane AI** (formerly PI Chat) | Plane AI | Plane AI | Plane AI | Plane AI | Plane AI | Plane AI | Чат ИИ, AI 聊天, AIチャット, AI 채팅, Chat IA, AI Çet, PI Chat (legacy) | +| **Power K** | Power K | Power K | Power K | Power K | Power K | Power K | Command K, Command Palette, コマンドパレット, 命令面板, Палитра команд | +| **PQL** | PQL | PQL | PQL | PQL | PQL | PQL | any expansion of the acronym into the target language | +| **Intake** (feature name) | Intake | Intake | Intake | Intake | Intake | Intake | Inbox, 受信箱, 收件箱, Входящие, Triage, Boîte de réception | +| **Active Cycles** (workspace view) | Active Cycles | Active Cycles | Active Cycles | Active Cycles | Active Cycles | Active Cycles | Translate as a unit; never split into generic "active" + localized "cycles" | +| **Sticky** / **Stickies** | Sticky / Stickies | Sticky / Stickies | Sticky / Stickies | Sticky / Stickies (Latin inside Chinese text) | Sticky / Stickies | Sticky / Stickies | 便签, 便利貼, メモ, 付箋, スティッキー, 메모, 스티키, заметка, Стикер, Note, Nota | +| **Pro** (plan tier) | Pro | Pro | Pro | Pro | Pro | Pro | Профессиональный, プロフェッショナル, 专业版, 專業版, Profesional | +| **Business** (plan tier) | Business | Business | Business | Business | Business | Business | Бизнес, ビジネス, 商业版, 商務版, Negocios, Negócios | +| **Enterprise** (plan tier) | Enterprise | Enterprise | Enterprise | Enterprise | Enterprise | Enterprise | Корпоративный, エンタープライズ, 企业版, 企業版, Empresarial | + +### Plane feature noun translation glossary (translate, do not preserve) + +These are common nouns. **Translate them into the target language** using the canonical form below. This follows Microsoft, Apple, and Mozilla style guides for product-UI translation: feature common nouns belong in the user's language; only trademarks, brand marks, and acronyms stay Latin. Leaving these in Latin in non-Latin locales reads as half-translated and is a measurable quality defect. + +Use the table verbatim — never coin new variants; never leave the Latin form in the locale value. + +| Source | zh-CN | zh-TW | ja | ko | ru | ua | de | fr | es | it | pt-BR | pl | cs | sk | ro | tr-TR | vi-VN | id | +| ----------- | ----- | ----- | ---------- | ------ | -------- | -------- | ------ | ------- | ------- | ------ | ------- | ------ | ------- | ------- | ------- | -------- | ------ | ------- | +| **Cycle** | 周期 | 週期 | サイクル | 사이클 | Цикл | Цикл | Zyklus | Cycle | Ciclo | Ciclo | Ciclo | Cykl | Cyklus | Cyklus | Ciclu | Döngü | Chu kỳ | Siklus | +| **Cycles** | 周期 | 週期 | サイクル | 사이클 | Циклы | Цикли | Zyklen | Cycles | Ciclos | Cicli | Ciclos | Cykle | Cykly | Cykly | Cicluri | Döngüler | Chu kỳ | Siklus | +| **Module** | 模块 | 模組 | モジュール | 모듈 | Модуль | Модуль | Modul | Module | Módulo | Modulo | Módulo | Moduł | Modul | Modul | Modul | Modül | Mô-đun | Modul | +| **Modules** | 模块 | 模組 | モジュール | 모듈 | Модули | Модулі | Module | Modules | Módulos | Moduli | Módulos | Moduły | Moduly | Moduly | Module | Modüller | Mô-đun | Modul | +| **Epic** | 史诗 | 史詩 | エピック | 에픽 | Эпик | Епік | Epic | Epic | Epic | Epic | Epic | Epik | Epik | Epik | Epic | Epik | Epic | Epik | +| **Epics** | 史诗 | 史詩 | エピック | 에픽 | Эпики | Епіки | Epics | Epics | Epics | Epics | Epics | Epiki | Epiky | Epiky | Epice | Epikler | Epic | Epik | +| **Page** | 页面 | 頁面 | ページ | 페이지 | Страница | Сторінка | Seite | Page | Página | Pagina | Página | Strona | Stránka | Stránka | Pagină | Sayfa | Trang | Halaman | +| **Pages** | 页面 | 頁面 | ページ | 페이지 | Страницы | Сторінки | Seiten | Pages | Páginas | Pagine | Páginas | Strony | Stránky | Stránky | Pagini | Sayfalar | Trang | Halaman | + +Notes: + +- Single-form locales (zh-CN, zh-TW, ja, ko, vi-VN, id) use the same form for singular and plural — the column repeats by design. +- Slavic locales (ru, ua, pl, cs, sk) show **nominative singular** and **nominative plural**. Inside ICU `{count, plural, ...}` blocks, use the case-correct form per CLDR keyword (see the Slavic case-form table further down). +- Some Latin locales (fr, vi-VN, id) have forms identical to English (`Cycle`, `Module`, `Page`) — that's the natural cognate, not a Latin-preservation rule. +- **Epic / Epics**: stays Latin in most Latin locales because there's no clean cognate (Spanish `épico` is for poetry/film; same for it/pt-BR/de/fr). Slavic locales use phonetic transliteration that's standard in their software industry (Эпик, Епік, Epik). CJK locales use the literal/transliterated form (史诗, エピック, 에픽). +- **Generic uses translate normally and don't follow this glossary:** `next page` (paginator), `the page` (browser refresh), `status page`, `web page`, `life cycle`, `release cycle`, `rate-limit cycle`, `cycle of releases` — these are not the Plane Cycle/Page feature; translate them as ordinary words in the surrounding prose. +- When editing an existing file, migrate occurrences you are already touching. Do not bulk-rewrite unrelated strings in the same PR — land a separate sweep PR with the `chore(i18n):` prefix. + +#### Slavic case forms inside ICU plural blocks + +When a Slavic plural block counts a Plane feature noun, use these forms: + +| Locale | Term | one (nom.sg.) | few | many | other | +| ------ | -------- | ------------- | -------- | -------- | -------- | +| **ru** | Цикл | Цикл | Цикла | Циклов | Цикла | +| **ru** | Модуль | Модуль | Модуля | Модулей | Модуля | +| **ru** | Эпик | Эпик | Эпика | Эпиков | Эпика | +| **ru** | Страница | Страница | Страницы | Страниц | Страницы | +| **ua** | Цикл | Цикл | Цикла | Циклів | Цикла | +| **ua** | Модуль | Модуль | Модуля | Модулів | Модуля | +| **ua** | Епік | Епік | Епіка | Епіків | Епіка | +| **ua** | Сторінка | Сторінка | Сторінки | Сторінок | Сторінки | +| **pl** | Cykl | Cykl | Cykle | Cykli | Cyklu | +| **pl** | Moduł | Moduł | Moduły | Modułów | Modułu | +| **pl** | Epik | Epik | Epiki | Epików | Epika | +| **pl** | Strona | Strona | Strony | Stron | Strony | +| **cs** | Cyklus | Cyklus | Cykly | Cyklu | Cyklů | +| **cs** | Modul | Modul | Moduly | Modulu | Modulů | +| **cs** | Epik | Epik | Epiky | Epiku | Epiků | +| **cs** | Stránka | Stránka | Stránky | Stránky | Stránek | +| **sk** | Cyklus | Cyklus | Cykly | Cyklu | Cyklov | +| **sk** | Modul | Modul | Moduly | Modulu | Modulov | +| **sk** | Epik | Epik | Epiky | Epiku | Epikov | +| **sk** | Stránka | Stránka | Stránky | Stránky | Stránok | + +Per CLDR for ru/ua: `one` fires for 1, 21, 31… (nom.sg.); `few` for 2–4, 22–24… (gen.sg.); `many` for 0, 5–20, 25–30… (gen.pl.); `other` for non-integer counts (gen.sg.). For pl: `one` (1), `few` (2–4 not 12–14, nom.pl.), `many` (0, 5–20, gen.pl.), `other` (decimals, gen.sg.). cs/sk: `one` (1), `few` (2–4, nom.pl.), `many` (decimals, gen.sg.), `other` (0, 5+, gen.pl.). + +### Third-party products & standards (always Latin, every locale) + +GitHub, GitLab, Bitbucket, Slack, Discord, Zoom, Microsoft Teams, Jira, Linear, Asana, Notion, Confluence, Trello, Figma, Google, Google Drive, Google Calendar, Google Docs, Google Sheets, Gmail, YouTube, Dropbox, Zapier, Tiptap, ProseMirror, Yjs, Hocuspocus, Socket.IO, React, TypeScript, JavaScript, Python, Django, PostgreSQL, Redis, RabbitMQ. + +Also preserve: OAuth, OIDC, SAML, SSO, LDAP, API, REST, GraphQL, URL, URI, ID, UUID, JSON, YAML, CSV, TSV, XML, HTML, PDF, PNG, JPG, SVG, CSS, HTTP, HTTPS, TLS, SSL, IP, CIDR, DNS, ARIA, WCAG. + +### Inside-string tokens (mechanical — preserve exactly) + +- **ICU variables**: `{count}`, `{name}`, `{workspace}`, `{userName}` — never translate, never rename, never move inside `{}`. +- **ICU pluralization / select keywords**: `plural`, `select`, `one`, `few`, `many`, `other`, `zero`, `two`, `=0`, `=1`, `#` — never translate. +- **HTML & JSX tags**: `…`, `…`, `
`, `…` — preserve attributes and nesting; translate only the visible text between tags. +- **i18next `Trans` numbered fragments**: `<0>…`, `<1>…` — preserve the numbers exactly; only translate the wrapped text. Never renumber. +- **Markdown**: `**bold**`, `*italic*`, `` `code` ``, `[text](url)` — keep the syntax; translate only human-readable prose. +- **Escape sequences**: `\n` (newline), `\t`, `\"`, `\\` — preserve at the same positions. +- **Keyboard keys & modifiers**: `Ctrl`, `Cmd`, `Shift`, `Alt`, `Option`, `Enter`, `Return`, `Esc`, `Tab`, `Space`, `Backspace`, arrow glyphs (`↑↓←→`), and OS glyphs (`⌘⌥⌃⇧`) — preserve exactly as shown on the physical key. +- **File extensions & formats**: `.json`, `.csv`, `MM/DD/YYYY` format strings — preserve. + +## Per-Script Rules + +Apply by target-language script, not by locale list. Adding a new Latin-script locale? It follows the Latin-script rules below. Adding a new Cyrillic locale? It follows the Cyrillic section. Scripts not yet shown here (Arabic, Hebrew, Thai, Greek, Hindi/Devanagari, etc.) — see **Adding a locale not documented here**. + +### Latin-script locales + +Currently includes fr, es, it, de, pt-BR, pl, cs, sk, ro, tr-TR, vi-VN, id — and any future locale that uses the Latin alphabet (hu, nl, sv, da, nb, fi, hr, bg-using-Latin variants, etc.). + +**Translate Plane feature nouns** (Cycle, Cycles, Module, Modules, Epic, Epics, Page, Pages) using the per-locale form from the glossary above. **Keep Latin** for Plane brand marks (Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake), plan tier names (Pro, Business, Enterprise), and third-party brands (GitHub, Slack, etc.). + +``` +✅ "Créer un Cycle" (fr — natural cognate from glossary, identical to English) +✅ "Crear un nuevo Ciclo" (es — natural Spanish cognate from glossary) +✅ "Archiviare questo Modulo" (it — natural Italian cognate from glossary) +✅ "Nueva Epic" (es — Epic has no clean cognate; stays Latin per glossary) +✅ "Archivieren Sie diesen Zyklus" (de — natural German form from glossary) +✅ "Buat Siklus baru" (id — natural Indonesian form from glossary) +✅ "Buat Sticky baru" (id — Sticky is a Plane brand mark, stays Latin) +✅ "Wechseln Sie zum Pro-Plan" (de — Pro is a plan tier name, stays Latin) +❌ "Créer un Cycle" WRONG when the locale should be `Zyklus` (de). Always use the glossary form. +❌ "Archivieren Sie diesen Cycle" (de — feature noun was left in Latin; use Zyklus per glossary) +❌ "Créer un Cercle" (fr — invented translation; use the glossary form) +❌ "Nueva Saga" (es — translated "Epic" with the wrong cognate) +❌ "Buat Catatan Tempel" (id — translated "Sticky" which is a brand mark; keep Latin) +``` + +Generic uses translate normally and do not follow the glossary: a paginator's `next page` is `nächste Seite` / `página siguiente` (generic noun, not the Plane Pages feature). The glossary applies only when EN refers to the Plane product feature. + +### Japanese (ja) — natural Japanese rendering + +**Plane feature nouns** (Cycle, Module, Epic, Page) use the natural Japanese form per the glossary above (サイクル, モジュール, エピック, ページ — katakana for foreign-origin nouns; native Japanese where one applies, like 付箋 for an Apple/Microsoft-style "sticky note"). **Brand marks** stay in Latin: Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake, GitHub, Slack, Pro/Business/Enterprise. + +For new katakana coinages and existing translations, the long-vowel mark `ー` is added for words ending in `-er`, `-or`, `-ar`, `-y` in English. This is the Microsoft Japanese style-guide convention and the current industry default: + +- user → **ユーザー** (not ユーザ) +- server → **サーバー** (not サーバ) +- editor → **エディター** (not エディタ) +- property → **プロパティー** (_kept as_ プロパティ where the existing codebase already does — match the surrounding file's convention) + +Tone: polite form です・ます. Never plain form だ・である. Avoid over-formal 尊敬語・謙譲語 for SaaS product copy — it reads stilted. + +Quotation marks: 「」 for primary quotes, 『』 for nested or for titles of works. Full-width punctuation: 。、()・!?. + +### Korean (ko) — Hangul rendering + +**Plane feature nouns** (Cycle, Module, Epic, Page) use the natural Korean form per the glossary above (사이클, 모듈, 에픽, 페이지 — Hangul transliteration where the term is product-coined; native Korean word where one applies). **Brand marks** stay in Latin: Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake, GitHub, Slack, Pro/Business/Enterprise. + +For new terms not on the glossary: prefer the native Korean word where one exists (`설정` for Settings); use Hangul phonetic transliteration for product-coined nouns with no native equivalent. Do not coerce a phonetic loanword when a natural Korean word exists — `버킷` for "Bucket" reads as a foreign trademark, `장바구니` reads as a Korean noun. + +**Korean particle agreement**: when introducing a consonant-ending noun (사이클, 에픽, 모듈), follow with the consonant-form particle (을/은/이/과), not the vowel-form (를/는/가/와). `사이클을 추가` not `사이클를 추가`. + +Register: + +- **System and error messages** → 합니다체 (high-formal: 합니다, 됩니다, 입니다). Existing files in this register today: `ko/auth.json`, `ko/error/*.json`. +- **Empty states, onboarding microcopy, tooltips** → 해요체 acceptable (softer: 해요, 돼요, 이에요) if the existing file uses it. Existing files in this register today: `ko/empty-state.json`, `ko/tour.json`. Always match the surrounding file rather than introducing a new register mid-namespace. +- Never casual 반말. + +Punctuation: Western punctuation (`. , ? !`); straight quotes `"…"` and `'…'`. + +### Simplified Chinese (zh-CN) and Traditional Chinese (zh-TW) — translate feature nouns + +**Plane feature nouns** (Cycle, Module, Epic, Page) translate to natural Chinese per the glossary above (zh-CN: 周期, 模块, 史诗, 页面 — zh-TW: 週期, 模組, 史詩, 頁面). This is what Microsoft's zh-CN style guide and every mainstream zh-localized SaaS product (Notion, Slack, Atlassian) does for common feature nouns. + +**Brand marks stay Latin**: Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake, GitHub, Slack, Pro/Business/Enterprise — these are trademark-style terms. + +**Insert a half-width space on each side of embedded Latin tokens** — this is Microsoft's zh-CN guideline and required for legibility. **Exception**: no space between a Latin token and adjacent full-width punctuation (`。,;:?!`); the punctuation already supplies visual breathing room. + +``` +✅ "使用 GitHub 登录" (Latin brand → half-width spaces around) +✅ "创建周期" (zh-CN feature noun translated; no Latin = no spaces) +✅ "归档此史诗" (Plane Epic → 史诗 per glossary) +✅ "添加 Sticky" (Sticky is a brand mark → Latin + half-width space) +✅ "升级到 Pro" (Pro is a plan tier → Latin) +✅ "登录 GitHub。" (no space between Latin token and full-width period) +❌ "使用GitHub登录" (Latin brand without surrounding half-width space) +❌ "创建 Cycle" (feature noun left in Latin — should be 周期) +❌ "创建Cycle" (feature noun in Latin AND missing space) +❌ "登录 GitHub 。" (stray space before full-width period) +❌ "创建赛克" (invented transliteration of Cycle — use the glossary's 周期) +``` + +Punctuation is **full-width**: 。,?!;:. Quotes: + +- zh-CN: `"…"` (primary) and `'…'` (nested) +- zh-TW: `「…」` (primary) and `『…』` (nested) +- Work titles (book/movie/app/article names): `《…》` + +Variant discipline: zh-CN ≠ zh-TW. They differ in script (简体 vs 繁體) **and** vocabulary (视频 vs 影片; 软件 vs 軟體; 网络 vs 網路). Never mass-copy between the two directories. + +Register: 您 (formal polite) in Plane UI — the product is B2B/SaaS. Reserve 你 for consumer/youth contexts (not applicable here). + +### Cyrillic locales + +Currently includes ru, ua — and any future Cyrillic locale (bg-BG, sr-Cyrl, mk, be, kk-Cyrl, etc.). + +**Plane feature nouns** (Cycle, Module, Epic, Page) use the natural Cyrillic form per the glossary above (Цикл/Циклы, Модуль/Модули, Эпик/Эпики, Страница/Страницы in ru — Цикл/Цикли, Модуль/Модулі, Епік/Епіки, Сторінка/Сторінки in ua). **Brand marks** stay in Latin: Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake, GitHub, Slack, Pro/Business/Enterprise. + +For new terms not on the glossary: prefer the native Cyrillic word where one exists; use phonetic transliteration only when no native word applies (`Бакет` is wrong for "Bucket" — use `Корзина`/`Кошик`). + +**Slavic case forms inside `{count, plural, ...}` blocks** are case-correct per CLDR keyword (one=nom.sg., few=gen.sg., many=gen.pl., other=gen.sg.). See the case-form table in the glossary section. + +Register: + +- **ru**: formal **Вы** (capitalized) when addressing a **single user directly** in respectful/formal contexts (onboarding, settings, confirmation dialogs); lowercase **вы** for plural/general references ("все вы"). Default to capitalized Вы in tooltips/dialogs and lowercase вы in descriptive copy. +- **ua**: modern Ukrainian software convention is lowercase **ви** by default; capitalized **Ви** is reserved for very formal direct address. This is the opposite convention from Russian — do not copy-paste Russian capitalization rules into Ukrainian files. + +Punctuation: primary quotes `«…»`, nested `„…"`. + +**Plural forms — critical**: both ru and ua require `one / few / many / other`. See CLDR section below. + +## CLDR Plural Rules + +Every `{count, plural, …}` string must contain **every** keyword the target language's CLDR rule requires. `other` is always required, even in single-form languages. + +**Canonical source**: Unicode CLDR Language Plural Rules chart — the definitive, versioned list of required categories for every language. Always verify against the current CLDR chart when adding a locale, since rules occasionally shift across CLDR releases. Libraries like `Intl.PluralRules` can resolve the rule at runtime. + +Below are the required keywords for locales currently in the repo. When adding a new locale, look up its categories in CLDR and add the row here. + +| Locale | Required keywords | Example mapping | +| -------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| en, es, de, tr-TR, vi-VN, id, ja, ko, zh-CN, zh-TW | `one, other` (single-form locales may use `other` alone, but **always emit `other`**) | 1 → one; 2+ → other | +| fr | `one, many, other` | 0, 1 → one; 1 000 000 → many; else other | +| it | `one, many, other` | 1 → one; 1 000 000 → many; else other | +| pt-BR | `one, many, other` | 0, 1 → one; 1 000 000 → many; else other | +| ro | `one, few, other` | 1 → one; 0, 2–19 → few; 20+ → other | +| pl | `one, few, many, other` | 1 → one; 2–4 (not 12–14) → few; 0, 5–20, many-digit → many; decimals → other | +| cs | `one, few, many, other` | 1 → one; 2–4 → few; decimals → many; 0, 5+ → other | +| sk | `one, few, many, other` | same pattern as cs | +| ru | `one, few, many, other` | 1, 21, 31… → one; 2–4, 22–24… → few; 0, 5–20, 25–30… → many; decimals → other | +| ua | `one, few, many, other` | same pattern as ru | +| ar (when added) | `zero, one, two, few, many, other` | Six categories — the maximum any language uses | + +> **Note on the consolidated row.** CLDR itself only requires `other` for `tr-TR`, `vi-VN`, `id`, `ja`, `ko`, `zh-CN`, `zh-TW` (single-form locales). Emitting both `one` and `other` with identical content is a **project convention** — it keeps tooling and linters consistent across the codebase. It is not a CLDR requirement, and `Intl.PluralRules` will return only `"other"` for these locales at runtime. + +For any locale not listed: look up the CLDR categories before writing a single plural string. Arabic has six; Welsh has six; most Slavic languages have four; most Romance languages have two or three; CJK / Turkic / Thai have one (still emit `other`). + +Rules in practice: + +1. **Never drop a required form**, even when the word is identical across forms — emit each one explicitly. +2. **Never add a form the target doesn't have.** German does **not** use `few`. Any `few` clause in `de/*.json` is a bug and must be removed. +3. In single-form locales (ja/ko/zh/tr/vi/id), emit `one` + `other` with identical content so tooling and linters stay consistent. +4. The `other` case is always the fallback — it must render a grammatically complete sentence on its own. + +Examples: + +```json +// ✅ Correct — Russian (four forms; `other` fires for non-integer counts and takes genitive singular) +"members": "{count, plural, one {# участник} few {# участника} many {# участников} other {# участника}}" + +// ✅ Correct — Polish (four forms) +"items": "{count, plural, one {# element} few {# elementy} many {# elementów} other {# elementu}}" + +// ✅ Correct — Romanian (three forms) +"days": "{count, plural, one {# zi} few {# zile} other {# de zile}}" + +// ✅ Correct — French (three forms; `many` covers 1 000 000, 2 000 000, …) +"members": "{count, plural, one {# membre} many {# membres} other {# membres}}" + +// ✅ Correct — Japanese (single form, both keywords emitted) +"label": "{count, plural, one {サイクル} other {サイクル}}" + +// ❌ Wrong — German with spurious `few` +"label": "{count, plural, one {Zyklus} few {Zyklen} other {Zyklen}}" + +// ❌ Wrong — Russian missing `few` and `many` +"label": "{count, plural, one {Цикл} other {Циклы}}" +``` + +## Placeholders, HTML, Markdown (preservation checklist) + +For every translated string, verify before saving: + +- [ ] Identical set of `{variables}` in source and target (character-for-character — `{userName}` is not `{username}`) +- [ ] Identical HTML/JSX tags with identical attributes and nesting +- [ ] Identical i18next `Trans` numbered tags (`<0>…`, `<1>…` — never renumber) +- [ ] Identical Markdown syntax (`**bold**`, `` `code` ``, `[link text](url)`) +- [ ] Identical `\n` positions (layout relies on these) +- [ ] No additions (no extra explanatory words), no omissions (no collapsed clauses) + +Variables **may be repositioned** for grammatical fit: + +``` +en: "Welcome, {name}! You have {count} new work items." +fr: "Bienvenue, {name} ! Vous avez {count} nouveaux work items." +ja: "{name}さん、ようこそ。{count}件の新しい作業項目があります。" +``` + +## Per-Locale Punctuation & Spacing + +Apply in every translated string. These are MQM "Locale Conventions" violations when missed — a measurable quality defect. + +Table below covers locales currently in the repo; add a row when you add a locale. + +| Locale | Key rules | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **fr** | Narrow NBSP (U+202F, fallback U+00A0) **before** `:`, `;`, `?`, `!`, `%`, `»` and **after** `«`. Primary quotes `« »` (with NBSPs), nested `"…"`. | +| **de** | Primary quotes `„…"` (low-high); **no** NBSP before punctuation. Avoid imperatives in system strings; prefer infinitive constructions ("Werkelement erstellen", not "Erstelle ein Werkelement"). | +| **es** | Inverted `¿…?` and `¡…!` at the start of questions/exclamations. Primary quotes `«…»` or `"…"`. | +| **it** | Primary quotes `«…»` or `"…"`. | +| **pt-BR** | Straight quotes `"…"`. | +| **pl** | Primary quotes `„…"`. | +| **cs / sk** | Primary quotes `„…"`. | +| **ro** | Primary quotes `„…"`. | +| **tr-TR / vi-VN / id** | Straight quotes `"…"`. Vietnamese diacritics are mandatory — never strip (no `bạn` → `ban`). | +| **ru / ua** | Primary `«…»`, nested `„…"`. | +| **ja** | Full-width `。、()!?・`. Primary quotes 「」, nested 『』. Ellipsis `…` (U+2026), typically doubled as `……`. | +| **ko** | Western `. , ? !`; straight quotes `"…"`. | +| **zh-CN** | Full-width `。,?!;:`. Primary `"…"`, nested `'…'`. Work titles `《…》`. Half-width space around embedded Latin tokens. | +| **zh-TW** | Full-width `。,?!;:`. Primary `「…」`, nested `『…』`. Work titles `《…》`. Half-width space around embedded Latin tokens. | + +## Tone & Register (SaaS defaults) + +Formality defaults for locales currently in the repo. Add a row for each new locale, consulting the Microsoft Style Guide for that locale as the default authority on product-UI register. + +| Locale | Default "you" | Notes | +| ------- | ---------------------------------------------------------------------------- | ------------------------------------------------ | +| fr | **vous** | Never "tu" in product UI | +| es | **usted** + **ustedes** | Neutral Spanish — no "vosotros", no "tú" | +| it | **Lei** (formal third-person) | B2B/enterprise convention | +| de | **Sie** | Use infinitive constructions for system messages | +| pt-BR | **você** | Semi-formal default; never "tu" | +| pl | **Pan/Pani** + 3rd-person, or impersonal | Never "ty" | +| cs / sk | **Vy** (formal, 3rd-person plural) | Never "ty" | +| ro | **dumneavoastră** (formal) | Or impersonal | +| tr-TR | **siz** + `-iniz` endings | Never "sen" | +| vi-VN | **bạn** (safe neutral) | Never "mày/tao"; consistency > variety | +| id | **Anda** (always capitalized) | Never "kamu" in product UI | +| ja | **です・ます体** | Never plain form; avoid 尊敬語・謙譲語 | +| ko | **합니다체** for system/errors; **해요체** acceptable for onboarding | Match surrounding file | +| zh-CN | **您** | B2B convention; never 你 | +| zh-TW | **您** | B2B convention; never 你 | +| ru | **Вы** (cap.) for singular direct address, **вы** (lower) for plural/general | Context-dependent | +| ua | **ви** (lowercase, modern convention) | Capitalized Ви only for very formal | + +General writing rules (apply across all locales): + +- Sentence case in buttons, headings, tooltips — not Title Case. Only capitalize proper nouns. +- No exclamation marks except in genuine celebrations (first success, milestone). +- No emoji in UI copy. +- No slang, idioms, humor, cultural references — they don't translate. +- Consistent terminology — if "work item" is used once, never switch to "issue" / "ticket" mid-flow. +- Active voice, present tense. +- Write out abbreviations on first use if not on the DNT list. + +## Text Expansion Budget + +Design strings knowing translations will grow. Typical expansion vs. English for locales currently in the repo; lookup values from Andiamo / Eriksen / W3C tables when adding a locale. + +| Locale | Expansion | Implication | +| ------------ | ------------------------------------------------- | ----------------------------------------------- | +| de | +10–35%, single words up to +180% | Reserve generous space on buttons, tabs, labels | +| fr | +15–25% | | +| es | +15–30% | | +| it | +10–25% | | +| pt-BR | +15–30% | | +| pl | +20–30% | | +| cs, sk | +10–20% | | +| ro | +15–25% | | +| ru, ua | +15% typical, spikes to +30% | | +| tr-TR | +10–30% (agglutination) | | +| vi-VN | +30–40% | Diacritic-heavy, many small words | +| id | +10–20% | | +| ja | −10 to −55% character count (similar pixel width) | | +| ko | −10 to −15% | | +| zh-CN, zh-TW | −40% character count (≈2× char width) | | + +Rule of thumb: any UI surface must absorb **+35%** without truncation. If a string is length-capped, add a comment or context in the source. + +## Numbers, Dates, Currency, Units + +**Never hard-code formats.** Use ICU skeletons so the runtime localizes automatically: + +```json +"updated_on": "Updated {date, date, medium}", +"percent_done": "{ratio, number, percent} complete", +"item_count": "{count, number} items" +``` + +Never write `"{date}MM/DD/YYYY"` in the source — that guarantees a locale bug. + +Decimal/thousands separators (runtime-handled): + +- en: `1,234.56` — de/it/es/pt-BR/ru/ua/pl/cs/sk/tr-TR: `1.234,56` or `1 234,56` — fr: `1 234,56` (thin space) — ja/ko/zh: `1,234.56`. + +Date formats (runtime-handled): + +- en-US: `MM/DD/YYYY` — most of Europe: `DD.MM.YYYY` — fr: `DD/MM/YYYY` — ja: `YYYY/MM/DD` or `YYYY年MM月DD日` — zh: `YYYY年MM月DD日` — ko: `YYYY년 MM월 DD일`. + +Currency: prefer ISO codes (`USD`, `EUR`, `INR`) in dense UI; localize symbol position via ICU. Units: keep the system (metric/imperial) a product decision, localized consistently. + +## AI Translation Workflow + +The repo has no machine-readable translation-status field today (no sidecar, no `__meta`, no per-key flag) — so "preserve human-reviewed strings" must be enforced via git history, not status metadata. The disciplines below are what actually fires: + +1. **Inject the DNT glossary into every AI translation prompt** — both approved targets and forbidden renderings. LLMs do not natively honor glossaries; they obey them only when prompted. +2. **Prompt the variant explicitly**: `target=zh-CN` vs `target=zh-TW`, `target=pt-BR` vs `target=pt-PT`. Auto-detect calls blend them and produce mixed vocabulary. +3. **Pass 2–5 sentences of real context** per string (surrounding UI, screen, flow). Keep DNT instructions out of context — route them through the glossary/system prompt channel. +4. **Hallucination check**: AI output may add or drop content. Diff placeholder/tag inventory before and after; any mismatch is a reject. +5. **On re-translation, preserve human-touched lines.** Before overwriting any value in an existing locale file, run `git log -- ` (or `git blame -L ,`) on the key. If the most recent non-bot, non-mass-rewrite commit was authored by a human, treat that line as human-reviewed and keep it. Only overwrite lines whose last edit was a machine sweep or a copy-from-en migration. +6. **Variants by language-resource tier**: expect more review iterations for id, vi-VN, ua, ro (lower-resource LLMs) than for de, fr, es, ja (higher-resource). + +### Per-string review rubric (MQM-aligned) + +When reviewing a translation — yours or an AI's — score against these categories and reject if any Major issue is present: + +1. **Terminology** — DNT violation, inconsistent with the glossary above, or inconsistent with past strings in the same namespace +2. **Accuracy** — Mistranslation, addition (invented content), omission (dropped content), wrong variant (zh-CN vs zh-TW), hallucination +3. **Linguistic** — Grammar, punctuation (per-locale table), spelling, encoding (mojibake, half-width where full-width required) +4. **Style / Register** — Informal pronoun used where formal is required; inconsistent tone; idiom/cultural reference +5. **Locale conventions** — Date / number / currency format hard-coded; decimal separator wrong; keyboard key translated +6. **Markup** — Placeholder changed, HTML tag dropped/modified, Markdown broken, `Trans` numbered fragment renumbered +7. **Plural** — Missing required CLDR form; spurious form the language doesn't have; `#` or `=0` dropped + +## Workflow + +### Add a new key + +1. Add to `packages/i18n/src/locales/en/.json` first. (If the namespace file does not yet exist, see **Add a new namespace** below — that case has extra steps.) +2. Use in the component via `t("my.new_key")`. +3. Translate into every target locale present in `src/locales/` — one file at a time. **Do not** copy the English value into the non-English files as a shortcut — `sync-check` treats presence as synced and will silently ship English copy to every locale. +4. Run `pnpm --filter @plane/i18n run generate:types` (or let the build do it). +5. Run `pnpm --filter @plane/i18n run sync:check` — expect `0 missing, 0 stale, 0 collisions`. +6. Spot-check one non-Latin locale manually for punctuation/plural correctness. + +### Add a new namespace + +A "namespace" is a top-level JSON file (e.g. `common.json`, `auth.json`, `epic.json`). The set of valid namespace names is a hard-coded const array — adding a JSON file alone is not enough; the runtime will not load it. + +1. Add the new namespace name to the `NAMESPACES` array in `packages/i18n/src/constants/namespaces.ts`. Keep alphabetical order with the existing entries. +2. Create `.json` in **every** locale directory under `packages/i18n/src/locales/` — start with `en/.json` (the source of truth), then create the file in all 18 target locales. An empty `{}` is fine for the targets at this step; `sync-check` will report missing keys as you add them in step 4. +3. Add at least one key in `en/.json` so the namespace has content. +4. Translate every key from `en/.json` into each target locale — apply every rule in this skill. +5. Run `pnpm --filter @plane/i18n run generate:types` to regenerate the `TTranslationKeys` union so component-level `t()` calls type-check. +6. Run `pnpm --filter @plane/i18n run sync:check` — expect `0 missing, 0 stale, 0 collisions`. +7. Use the new namespace from a component via the namespace-prefixed key (e.g. `t(".my_key")`) and spot-check the rendered UI in at least one non-Latin locale. + +### Update an English value + +The meaning changed — every target is now stale even if the key still exists. `sync-check` will **not** flag this. + +1. Edit `en/.json`. +2. Update the same key in every target locale in the same PR. +3. If a locale has no native speaker available in the PR, mark the string status as `machine_translated` (in PR description or commit message) and open a follow-up for native review. + +### Add a new language + +1. `cp -r packages/i18n/src/locales/en packages/i18n/src/locales/` +2. Translate every file, applying every rule in this skill. +3. Register in `packages/i18n/src/constants/language.ts` (`SUPPORTED_LANGUAGES`) and `packages/i18n/src/types/language.ts` (`TLanguage`). +4. Run `sync:check` — must show 100% coverage before merging. +5. Before merging, **pseudolocalize**: temporarily replace the new locale's values with bracketed expanded forms (`"Plane" → "[Plàññéé——]"`) in a local build and click through the UI. Catches truncation, unextracted strings, and layout bugs before real users see them. +6. If the new locale isn't yet covered by this skill (script, plural rules, punctuation, register), follow **Adding a locale not documented here** below and update this file in the same PR. + +### Commands + +```bash +# Regenerate the TTranslationKeys union (auto on build) +pnpm --filter @plane/i18n run generate:types + +# Report drift +pnpm --filter @plane/i18n run sync:check + +# Same, exit 1 on drift (for CI) +pnpm --filter @plane/i18n run check:sync +``` + +## Quick Reference + +| Question | Answer | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Translate "Plane" / "Plane AI" / "Power K" / "PQL"? | Never. Latin, every locale. | +| Translate "Sticky" / "Stickies" / "Intake"? | Never. Plane brand marks. Latin, every locale. | +| Translate "Active Cycles"? | Never as a unit (it's a feature page name). Lowercase generic "active cycles" in prose translates normally per the glossary. | +| Translate "Pro" / "Business" / "Enterprise"? | Never. Plan tier names. Latin, every locale. | +| Translate "Cycle" / "Module" / "Epic" / "Page"? | **Yes** — use the per-locale form from the translation glossary (zh-CN 周期/模块/史诗/页面, ja サイクル/モジュール/エピック/ページ, de Zyklus/Modul/Epic/Seite, etc.). | +| Translate "GitHub" / "Slack" / third-party brands? | Never. Latin, every locale. | +| Translate a variable name `{name}`? | Never. Preserve exactly. | +| Translate `<0>…`? | Translate only the inside text. Never renumber. | +| Russian plural forms? | `one / few / many / other` — four forms mandatory; case-correct per CLDR. | +| German plural forms? | `one / other` — never `few`. | +| French plural forms? | `one / many / other` — `many` covers 1M+. | +| CJK plural forms? | `one / other` (single-form) — still emit both. | +| Informal "you" in de/fr/ru/ja? | Never in product UI. | +| Chinese + embedded "GitHub"? | `使用 GitHub 登录` — half-width space around Latin **brand** tokens. Feature nouns translate (`创建周期`, no space because no Latin). | +| Japanese "user"? | ユーザー with long-vowel ー, not ユーザ. | +| Hard-code date formats? | Never. Use ICU `{d, date, medium}`. | +| Copy English into non-English locale? | Never. Worse than leaving the key missing. | + +## Common Mistakes (revert on sight) + +- **Translating Plane brand marks** — `Plane → 飞机`, `Plane AI → AI 聊天`, `Power K → 命令面板`, `Sticky → 便签` (Sticky is a brand, even though "sticky note" generally translates), `Intake → 收件箱`. Brand marks stay Latin. +- **Leaving feature nouns in Latin in non-Latin locales** — `创建 Cycle` (zh-CN), `Создать Cycle` (ru), `エピックを作成` is fine but `Epicを作成` is not. Use the per-locale form from the glossary. +- **Coining new feature-noun translations** — `Cycle → Cercle` (fr — invented; the natural cognate is the same `Cycle`), `Cycle → 循环` (zh-CN — non-glossary; use `周期`), `Epic → Saga` (es — non-glossary; use `Epic`). The glossary is the source of truth. +- **Missing `few` / `many` in Slavic languages** — Russian/Polish/Czech/Slovak/Ukrainian strings with only `one / other` are grammatically wrong for counts 2–4 and 5+. Fix in the same PR. +- **Wrong Slavic case form inside ICU plurals** — for ru/ua, `few` is genitive singular (`# Цикла`), not nominative plural (`# Циклы`). See the case-form table. +- **Inventing `few` in German, `many` in Spanish, etc.** — languages not in CLDR for that form. Remove the spurious keyword. +- **Translating `{count}` or renaming `{name}`** — ICU variables are lookup keys; rename breaks runtime substitution. +- **Dropping `<0>`/`<1>` numbering in `Trans`** — react-i18next matches by number; renumbering breaks the component. +- **Copying English as a translation** — `sync-check` passes, users see English. The fastest way to ship bad i18n. +- **No half-width space around Latin tokens in Chinese** — `使用GitHub登录` is a readability bug. The space rule applies around any remaining Latin token (brand mark), not around translated feature nouns. +- **Missing `ー` in Japanese katakana (`ユーザ` instead of `ユーザー`)** — inconsistent with the Microsoft style guide and the rest of the product. +- **Using informal pronouns** — `du/tu/ты/tú/you (informal)` breaks Plane's formal register in languages that distinguish. +- **Translating keyboard keys** — `Ctrl` stays `Ctrl`, never `Strg` or `コントロール`, because the physical key says `Ctrl`. +- **Hard-coded date/number strings** — `"Due on MM/DD/YYYY"` is a locale bug waiting to ship; use ICU formatters. +- **Translating `PQL`, `SSO`, `API`** — acronyms are DNT. The prose around them may be translated. +- **Translating plan tier names** — `Pro → Профессиональный`, `Business → 商业版`, `Enterprise → エンタープライズ`. Plan tiers stay Latin per industry convention (Notion/Slack/Linear/Asana). +- **Korean particle mismatch after Hangul transliteration** — `사이클를` is wrong (vowel-particle after consonant-ending noun); use `사이클을`. Same for 모듈, 에픽. +- **Vietnamese diacritic stripping** — `bạn` is not `ban`; diacritics are mandatory. +- **Lowercase `anda` in Indonesian** — `Anda` is always capitalized in product UI. +- **Mixing zh-CN and zh-TW vocabulary** — `视频` ≠ `影片`, `软件` ≠ `軟體`. Never copy-paste between the two directories. + +## Red Flags — Stop and Revert + +You see yourself about to do any of the following → stop, delete, restart this string: + +- Replacing `Plane`, `Plane AI`, `Power K`, `PQL`, `Active Cycles`, `Sticky`, `Stickies`, `Intake`, `Pro`, `Business`, `Enterprise`, or any third-party brand (`GitHub`, `Slack`, etc.) with a translated form. +- Leaving `Cycle` / `Module` / `Epic` / `Page` in Latin in a non-Latin locale (zh-CN, zh-TW, ja, ko, ru, ua) — these are common nouns and must be translated per the glossary. +- Coining a feature-noun translation that's not in the glossary (`Cycle → 循环`, `Cycle → Cercle`, `Epic → Saga`). +- Using a vowel-form Korean particle (을/은/이/과 vs. 를/는/가/와) that doesn't agree with the noun's final character. +- Pasting the English value into a non-English file. +- Renaming or removing a `{variable}`, ``, numbered `<0>`, or Markdown marker. +- Dropping `few` or `many` from a Slavic-language plural, or adding `few` to German. +- Using nominative plural for `few` in ru/ua plural blocks (it should be genitive singular per CLDR). +- Editing one locale file and deciding to "do the others later" without updating the PR description. +- Using `du`, `tu`, `ты`, `tú`, plain-form Japanese, or 반말 Korean in any product string. +- Stripping diacritics from Vietnamese, lowercasing `Anda` in Indonesian. +- Hard-coding a date or number format. + +## Rationalizations — Use Reality Column Instead + +| Excuse | Reality | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| "Keeping `Cycle` Latin in zh-CN keeps it consistent with English docs." | That's a minority position; Microsoft, Apple, Mozilla, Notion, Atlassian, Slack all translate. Monolingual zh users can't read Latin words; the glossary form is the standard. | +| "史诗 is the established Chinese word for Epic, but Latin reads more brand-y." | Brand-y is the wrong goal for a feature noun. The glossary form (`史诗` for zh-CN, `Эпик` for ru) is what users in those locales expect from a SaaS product. | +| "Our Russian users understand `Cycle` Latin." | They understand it; they don't expect to encounter it. The glossary form (`Цикл`) is what every other Russian SaaS product uses for the same concept. | +| "I'll just transliterate the Plane brand name into Cyrillic for accessibility." | Brand marks stay Latin in every locale. `Плейн` is wrong; `Plane` is right. | +| "Plan tier names should be translated since they're plain words." | Industry standard (Notion, Slack, Linear, Asana, GitHub) is to keep `Pro`, `Business`, `Enterprise` Latin for marketing consistency. Don't translate. | +| "This string is internal / rarely seen, good-enough is fine." | Every string is someone's main screen. Rules are cheap to follow; consistency compounds. | +| "`sync-check` passed, I'm done." | `sync-check` only compares presence, not value quality. Green does not mean translated. | +| "I'll fix the missing plural forms later." | Missing `few` in Russian renders the wrong word for counts 2–4 right now in production. Same PR. | +| "The old file already used Latin Cycle everywhere — stay consistent." | Consistency with a bug is still a bug. Migrate occurrences you touch; open a sweep PR for the rest. | +| "AI said this was the right Japanese word for Cycle." | AI does not know our glossary unless you inject it. Check against the glossary table above; the glossary wins. | +| "The German translation is a bit long but still fits on my screen." | It won't fit on every user's screen. Design for +35% expansion; test in a narrow viewport. | +| "I renamed `{userName}` to `{nomeUtente}` so the Italian reads naturally." | Variables are code. The runtime has no `{nomeUtente}` in scope — it renders as literal text. Revert. | +| "Capitalizing `Anda` / `您` / `Sie` looks over-formal." | It's Plane's register in those languages. Deviating breaks brand voice across the product. | + +## Adding a locale not documented here + +When you add a language whose script, plural rules, punctuation, or register defaults aren't covered above: + +1. **Plural rules** — Look up the target language in the Unicode CLDR Language Plural Rules chart. Note every required keyword (`zero`, `one`, `two`, `few`, `many`, `other`). Add a row to the CLDR table above. +2. **Script & transliteration** — Identify the script family. Latin → preserve DNT verbatim. Non-Latin → decide per-script: transliterate common-noun feature names phonetically (Cyrillic / Devanagari / Greek / Thai), keep brand marks in Latin. For RTL scripts (Arabic, Hebrew, Persian, Urdu) set `dir="rtl"` at the root and ensure Latin tokens remain LTR inside RTL context; preserve `‏`/`‎` markers if present. +3. **Punctuation & spacing** — Consult the Microsoft Style Guide for the locale (canonical authority on SaaS-style product punctuation). Add a row to the Per-Locale Punctuation & Spacing table. +4. **Register** — Default to the formal "you" and whatever honorific/polite register the Microsoft guide prescribes for B2B SaaS. Add a row to the Tone & Register table. +5. **Text expansion** — Look up typical expansion in Andiamo / Eriksen / W3C text-size tables; add a row to the Text Expansion Budget. +6. **DNT glossary** — Add a column (or row entries) for the new locale across the DNT tables. Specify forbidden literal translations (the words an LLM would produce if uninstructed). Example: if adding Arabic, the forbidden rendering for `Plane` includes `طائرة`; for `Epic` includes `ملحمة`; for `Sticky` includes `ملاحظة`. +7. **Commit this file in the same PR** that introduces the locale. The skill must stay ahead of the codebase — empty per-locale rows guarantee inconsistent translations in future PRs. + +Canonical references to consult: + +- **Unicode CLDR** — plural categories, number/date formats, collation. +- **Microsoft Style Guides** — per-locale register, punctuation, abbreviation handling, trademark rules (90+ locales covered). +- **Mozilla L10n Style Guides** — additional per-locale typography and brand-preservation rules. +- **W3C i18n** — RTL/bidi, placeholder handling, UTF-8, escapes, text expansion. + +## References + +- Microsoft Localization Style Guides — per-locale definitive source, formal-register defaults +- Unicode CLDR Plural Rules — canonical per-locale plural category list +- Mozilla L10n Style Guides — trademark/brand preservation rules, per-locale typography +- W3C i18n Quick Tips — placeholder handling, text expansion, UTF-8 and escapes +- Google developer style guide "Writing for a global audience" — source-string best practices +- MQM (Multidimensional Quality Metrics) — review rubric used in this skill's `Per-string review rubric` section +- Microsoft AI/LLMs for translation guidance — glossary injection, variant prompting, human-review gates diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e0b681f --- /dev/null +++ b/.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/.env.example b/.env.example new file mode 100644 index 0000000..90efa8b --- /dev/null +++ b/.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/.gitattributes b/.gitattributes new file mode 100644 index 0000000..526c8a3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml new file mode 100644 index 0000000..bfec698 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -0,0 +1,76 @@ +name: Bug report +description: Create a bug report to help us improve Plane +title: "[bug]: " +type: 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: edition + attributes: + label: Edition + description: Which edition of Plane are you using? + options: + - Community + - Commercial + - Cloud + validations: + required: true +- type: input + id: version + attributes: + label: Version + description: Which version of Plane are you using? If Cloud, mention it as latest + placeholder: v0.17.0-dev + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/--feature-request.yaml b/.github/ISSUE_TEMPLATE/--feature-request.yaml new file mode 100644 index 0000000..db154f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/--feature-request.yaml @@ -0,0 +1,30 @@ +name: Feature request +description: Suggest a feature to improve Plane +title: "[feature]: " +type: 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/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..301080b --- /dev/null +++ b/.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/.github/instructions/bash.instructions.md b/.github/instructions/bash.instructions.md new file mode 100644 index 0000000..b14d3c9 --- /dev/null +++ b/.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/apps/api/templates/csrf_failure.html b/apps/api/templates/csrf_failure.html new file mode 100644 index 0000000..b5a58cb --- /dev/null +++ b/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/apps/api/templates/emails/auth/forgot_password.html b/apps/api/templates/emails/auth/forgot_password.html new file mode 100644 index 0000000..29c9b46 --- /dev/null +++ b/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/apps/api/templates/emails/auth/magic_signin.html b/apps/api/templates/emails/auth/magic_signin.html new file mode 100644 index 0000000..a52700f --- /dev/null +++ b/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/apps/api/templates/emails/exports/analytics.html b/apps/api/templates/emails/exports/analytics.html new file mode 100644 index 0000000..f9a250d --- /dev/null +++ b/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/apps/api/templates/emails/invitations/project_invitation.html b/apps/api/templates/emails/invitations/project_invitation.html new file mode 100644 index 0000000..f18579c --- /dev/null +++ b/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/apps/api/templates/emails/invitations/workspace_invitation.html b/apps/api/templates/emails/invitations/workspace_invitation.html new file mode 100644 index 0000000..84ba654 --- /dev/null +++ b/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/apps/api/templates/emails/notifications/issue-updates.html b/apps/api/templates/emails/notifications/issue-updates.html new file mode 100644 index 0000000..d82614d --- /dev/null +++ b/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/apps/api/templates/emails/notifications/project_addition.html b/apps/api/templates/emails/notifications/project_addition.html new file mode 100644 index 0000000..bc779f5 --- /dev/null +++ b/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/apps/api/templates/emails/notifications/webhook-deactivate.html b/apps/api/templates/emails/notifications/webhook-deactivate.html new file mode 100644 index 0000000..c094eaa --- /dev/null +++ b/apps/api/templates/emails/notifications/webhook-deactivate.html @@ -0,0 +1,298 @@ + + + + + + + + {{ message }} + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/api/templates/emails/test_email.html b/apps/api/templates/emails/test_email.html new file mode 100644 index 0000000..4e4d3d9 --- /dev/null +++ b/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/apps/api/templates/emails/user/email_updated.html b/apps/api/templates/emails/user/email_updated.html new file mode 100644 index 0000000..cb1cad4 --- /dev/null +++ b/apps/api/templates/emails/user/email_updated.html @@ -0,0 +1,1058 @@ + + + + + + + + Plane email address successfully updated + + + + + + + + + + + + + + diff --git a/apps/api/templates/emails/user/user_activation.html b/apps/api/templates/emails/user/user_activation.html new file mode 100644 index 0000000..004aa95 --- /dev/null +++ b/apps/api/templates/emails/user/user_activation.html @@ -0,0 +1,1570 @@ + + + + + + + + Your Plane account is now active + + + + + + + + + + + + + + + diff --git a/apps/api/templates/emails/user/user_deactivation.html b/apps/api/templates/emails/user/user_deactivation.html new file mode 100644 index 0000000..a3020ac --- /dev/null +++ b/apps/api/templates/emails/user/user_deactivation.html @@ -0,0 +1,1571 @@ + + + + + + + + Your Plane account has been deactivated + + + + + + + + + + + + + + + diff --git a/apps/api/tests/RUNNING_TESTS.md b/apps/api/tests/RUNNING_TESTS.md new file mode 100644 index 0000000..9b60b2b --- /dev/null +++ b/apps/api/tests/RUNNING_TESTS.md @@ -0,0 +1,82 @@ +# Running the API Test Suite + +This guide covers running the Django/pytest suite for `apps/api` inside Docker via `docker-compose-test.yml` at the repo root. The compose file boots an isolated stack — Postgres, Valkey (Redis), RabbitMQ, MinIO — with tmpfs-backed data dirs, so every run begins from a clean slate and a single teardown command removes everything. + +For background on the test layout, markers, and fixtures, see [`TESTING_GUIDE.md`](./TESTING_GUIDE.md) and [`README.md`](./README.md). + +## Prerequisites + +- Docker and Docker Compose v2 (`docker compose ...`) +- Env files generated via the setup script: + + ```bash + ./setup.sh + ``` + + This copies `apps/api/.env.example` → `apps/api/.env` (along with the other app env files). The compose file reads `apps/api/.env`, so this step must run **before** the first `docker compose` invocation. + +## Running the suite + +All commands are run from the repo root. + +### Full suite + +```bash +docker compose -f docker-compose-test.yml up \ + --build \ + --abort-on-container-exit \ + --exit-code-from api-tests +``` + +- `--build` rebuilds the `api-tests` image when `Dockerfile.dev` or `requirements/*.txt` change. +- `--abort-on-container-exit` stops the dependency services as soon as `api-tests` exits. +- `--exit-code-from api-tests` propagates pytest's exit code so this works in CI. + +### Filtered runs + +Use `docker compose run` to override the default `pytest` command. Anything you pass after the service name is forwarded to pytest. + +```bash +# Only unit tests (marker defined in pytest.ini) +docker compose -f docker-compose-test.yml run --rm --build api-tests pytest -m unit + +# A single directory, filtered by name +docker compose -f docker-compose-test.yml run --rm api-tests \ + pytest plane/tests/unit -k "test_workspace" + +# Single file with verbose output +docker compose -f docker-compose-test.yml run --rm api-tests \ + pytest plane/tests/unit/models/test_workspace.py -vv +``` + +The available markers (`unit`, `contract`, `smoke`, `slow`) are declared in `apps/api/pytest.ini`. + +### Teardown + +```bash +docker compose -f docker-compose-test.yml down -v +``` + +`-v` removes the ephemeral volumes and the `test_env` network. Because the data directories are tmpfs, no host state survives a teardown — every run starts clean. Run this between unrelated test sessions to free Docker resources. + +## How it works + +| Service | Image | Purpose | +| ------------ | ------------------------------------ | --------------------------------------------- | +| `test-db` | `postgres:15.7-alpine` | Application database | +| `test-redis` | `valkey/valkey:7.2.11-alpine` | Cache / Celery broker | +| `test-mq` | `rabbitmq:3.13.6-management-alpine` | Task queue | +| `test-minio` | `minio/minio` | S3-compatible object storage | +| `api-tests` | built from `apps/api/Dockerfile.dev` | Installs `requirements/test.txt`, runs pytest | + +All four dependencies expose health checks; `api-tests` waits for `service_healthy` on each via `depends_on`, so pytest only starts once the stack is ready. + +Test-time env overrides live in the compose file itself (`POSTGRES_HOST=test-db`, `REDIS_URL=redis://test-redis:6379/`, `AWS_S3_ENDPOINT_URL=http://test-minio:9000`, `DJANGO_SETTINGS_MODULE=plane.settings.test`). Everything else is inherited from `apps/api/.env`. + +## Troubleshooting + +- **`./apps/api/.env: no such file or directory`** — run `./setup.sh` from the repo root. +- **Port already in use** — none of the test services publish host ports; if you see this it's coming from a different compose stack. Stop the local stack (`docker compose -f docker-compose-local.yml down`). +- **Stale image after dependency changes** — rebuild explicitly: `docker compose -f docker-compose-test.yml build --no-cache api-tests`. +- **MinIO bucket missing** — the `test-minio` entrypoint creates the bucket named by `AWS_S3_BUCKET_NAME` (default `uploads`). Change the value in `apps/api/.env` and re-run. +- **Database state leaking between runs** — confirm you ran `down -v` (not just `down`). The tmpfs mounts are torn down with the container, but the network and any externally created volumes need `-v` to clear. diff --git a/apps/live/.env.example b/apps/live/.env.example new file mode 100644 index 0000000..5fc90d7 --- /dev/null +++ b/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/apps/live/.prettierignore b/apps/live/.prettierignore new file mode 100644 index 0000000..b0b8bc6 --- /dev/null +++ b/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/apps/live/Dockerfile.dev b/apps/live/Dockerfile.dev new file mode 100644 index 0000000..5e0f537 --- /dev/null +++ b/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/apps/live/Dockerfile.live b/apps/live/Dockerfile.live new file mode 100644 index 0000000..9cecc2e --- /dev/null +++ b/apps/live/Dockerfile.live @@ -0,0 +1,85 @@ +# 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:$PNPM_HOME/bin:$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.18 +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 + +# OS security updates. --available forces reinstall from the current repos; +# bump APK_SECURITY_PATCH to bust buildx's cached layer (keyed on this command +# string), otherwise a rebuild silently re-ships the old packages. +ARG APK_SECURITY_PATCH=2026-09-07 +RUN echo "apk-security-patch ${APK_SECURITY_PATCH}" && apk update && apk upgrade --no-cache --available + +# Remove go from Alpine APK database; not needed at runtime and carries stdlib CVEs +RUN apk del go 2>/dev/null || true + +# npm is not used at runtime (CMD is plain node); its bundled dependencies carry +# known vulnerabilities (tar, brace-expansion, pacote, sigstore, ip-address, +# fast-uri, browserslist), so remove the whole CLI rather than picking off one +# package at a time. +RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx + +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 + +# esbuild and tsgolint are build-only Go binaries; remove from runtime image to eliminate stdlib CVEs +RUN find /app/node_modules \( -name 'esbuild' -o -name 'tsgolint' \) -type f -delete 2>/dev/null || true + +ENV TURBO_TELEMETRY_DISABLED=1 + +EXPOSE 3000 + +CMD ["node", "apps/live"] diff --git a/apps/live/package.json b/apps/live/package.json new file mode 100644 index 0000000..e2e13c2 --- /dev/null +++ b/apps/live/package.json @@ -0,0 +1,82 @@ +{ + "name": "live", + "version": "1.4.2", + "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": "catalog:", + "@effect/platform-node": "catalog:", + "@fontsource/inter": "catalog:", + "@hocuspocus/extension-database": "catalog:", + "@hocuspocus/extension-logger": "catalog:", + "@hocuspocus/extension-redis": "catalog:", + "@hocuspocus/server": "catalog:", + "@hocuspocus/transformer": "catalog:", + "@plane/decorators": "workspace:*", + "@plane/editor": "workspace:*", + "@plane/logger": "workspace:*", + "@plane/services": "workspace:*", + "@plane/types": "workspace:*", + "@plane/utils": "workspace:*", + "@react-pdf/renderer": "catalog:", + "@react-pdf/types": "catalog:", + "@tiptap/core": "catalog:", + "@tiptap/html": "catalog:", + "axios": "catalog:", + "compression": "catalog:", + "cors": "catalog:", + "dotenv": "catalog:", + "effect": "catalog:", + "express": "catalog:", + "express-ws": "catalog:", + "helmet": "catalog:", + "ioredis": "catalog:", + "react": "catalog:", + "sharp": "catalog:", + "uuid": "catalog:", + "ws": "catalog:", + "y-prosemirror": "catalog:", + "y-protocols": "catalog:", + "yjs": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@plane/typescript-config": "workspace:*", + "@types/compression": "catalog:", + "@types/cors": "catalog:", + "@types/express": "catalog:", + "@types/express-ws": "catalog:", + "@types/node": "catalog:", + "@types/pdf-parse": "catalog:", + "@types/react": "catalog:", + "@types/ws": "catalog:", + "@vitest/coverage-v8": "catalog:", + "pdf-parse": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/apps/live/src/controllers/collaboration.controller.ts b/apps/live/src/controllers/collaboration.controller.ts new file mode 100644 index 0000000..92ead80 --- /dev/null +++ b/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/apps/live/src/controllers/document.controller.ts b/apps/live/src/controllers/document.controller.ts new file mode 100644 index 0000000..3a0282f --- /dev/null +++ b/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/apps/live/src/controllers/health.controller.ts b/apps/live/src/controllers/health.controller.ts new file mode 100644 index 0000000..463d986 --- /dev/null +++ b/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/apps/live/src/controllers/index.ts b/apps/live/src/controllers/index.ts new file mode 100644 index 0000000..2ae3bce --- /dev/null +++ b/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/apps/live/src/controllers/pdf-export.controller.ts b/apps/live/src/controllers/pdf-export.controller.ts new file mode 100644 index 0000000..34c03d2 --- /dev/null +++ b/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/apps/live/src/env.ts b/apps/live/src/env.ts new file mode 100644 index 0000000..c9b61bd --- /dev/null +++ b/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/apps/live/src/extensions/database.ts b/apps/live/src/extensions/database.ts new file mode 100644 index 0000000..becefc8 --- /dev/null +++ b/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/apps/live/src/extensions/force-close-handler.ts b/apps/live/src/extensions/force-close-handler.ts new file mode 100644 index 0000000..b13e08e --- /dev/null +++ b/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/apps/live/src/extensions/index.ts b/apps/live/src/extensions/index.ts new file mode 100644 index 0000000..d55ca6e --- /dev/null +++ b/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/apps/live/src/extensions/logger.ts b/apps/live/src/extensions/logger.ts new file mode 100644 index 0000000..f670b66 --- /dev/null +++ b/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/apps/live/src/extensions/redis.ts b/apps/live/src/extensions/redis.ts new file mode 100644 index 0000000..900a001 --- /dev/null +++ b/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/apps/live/src/extensions/title-sync.ts b/apps/live/src/extensions/title-sync.ts new file mode 100644 index 0000000..c86b749 --- /dev/null +++ b/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/apps/live/src/extensions/title-update/debounce.ts b/apps/live/src/extensions/title-update/debounce.ts new file mode 100644 index 0000000..9de1ba4 --- /dev/null +++ b/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/apps/live/src/extensions/title-update/title-update-manager.ts b/apps/live/src/extensions/title-update/title-update-manager.ts new file mode 100644 index 0000000..5521c10 --- /dev/null +++ b/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/apps/live/src/extensions/title-update/title-utils.ts b/apps/live/src/extensions/title-update/title-utils.ts new file mode 100644 index 0000000..ac23948 --- /dev/null +++ b/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/apps/live/src/hocuspocus.ts b/apps/live/src/hocuspocus.ts new file mode 100644 index 0000000..93ebf72 --- /dev/null +++ b/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/apps/live/src/lib/auth-middleware.ts b/apps/live/src/lib/auth-middleware.ts new file mode 100644 index 0000000..fcf06f8 --- /dev/null +++ b/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/apps/live/src/lib/auth.ts b/apps/live/src/lib/auth.ts new file mode 100644 index 0000000..02aa69c --- /dev/null +++ b/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/apps/live/src/lib/errors.ts b/apps/live/src/lib/errors.ts new file mode 100644 index 0000000..4e2bc26 --- /dev/null +++ b/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/apps/live/src/lib/pdf/colors.ts b/apps/live/src/lib/pdf/colors.ts new file mode 100644 index 0000000..1b220d7 --- /dev/null +++ b/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/apps/live/src/lib/pdf/icons.tsx b/apps/live/src/lib/pdf/icons.tsx new file mode 100644 index 0000000..92621f3 --- /dev/null +++ b/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/apps/live/src/lib/pdf/index.ts b/apps/live/src/lib/pdf/index.ts new file mode 100644 index 0000000..f3fe478 --- /dev/null +++ b/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/apps/live/src/lib/pdf/mark-renderers.ts b/apps/live/src/lib/pdf/mark-renderers.ts new file mode 100644 index 0000000..1f40c4e --- /dev/null +++ b/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/apps/live/src/lib/pdf/node-renderers.tsx b/apps/live/src/lib/pdf/node-renderers.tsx new file mode 100644 index 0000000..003d21f --- /dev/null +++ b/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/apps/live/src/lib/pdf/plane-pdf-exporter.tsx b/apps/live/src/lib/pdf/plane-pdf-exporter.tsx new file mode 100644 index 0000000..f6c6b59 --- /dev/null +++ b/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/apps/live/src/lib/pdf/styles.ts b/apps/live/src/lib/pdf/styles.ts new file mode 100644 index 0000000..b55a156 --- /dev/null +++ b/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/apps/live/src/lib/pdf/types.ts b/apps/live/src/lib/pdf/types.ts new file mode 100644 index 0000000..0578a49 --- /dev/null +++ b/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/apps/live/src/lib/stateless.ts b/apps/live/src/lib/stateless.ts new file mode 100644 index 0000000..d59f8fe --- /dev/null +++ b/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/apps/live/src/redis.ts b/apps/live/src/redis.ts new file mode 100644 index 0000000..f23374a --- /dev/null +++ b/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/apps/live/src/schema/pdf-export.ts b/apps/live/src/schema/pdf-export.ts new file mode 100644 index 0000000..e3085ee --- /dev/null +++ b/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/apps/live/src/server.ts b/apps/live/src/server.ts new file mode 100644 index 0000000..9a3906b --- /dev/null +++ b/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/apps/live/src/services/api.service.ts b/apps/live/src/services/api.service.ts new file mode 100644 index 0000000..813c829 --- /dev/null +++ b/apps/live/src/services/api.service.ts @@ -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 type { AxiosInstance } from "axios"; +import { create } from "axios"; +import { normalizeAPIRequestURL } from "@plane/services"; +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 = create({ + baseURL: this.baseURL, + withCredentials: true, + timeout: 20000, + }); + this.setupInterceptors(); + } + + private setupInterceptors() { + this.axiosInstance.interceptors.request.use((config) => { + try { + if (config.url) { + config.url = normalizeAPIRequestURL(config.url, this.baseURL); + } + } catch (error) { + // Never block a request because of slash normalization — fall back to the + // original URL and let the call proceed. + console.warn("[APIService] Failed to normalize trailing slash:", config.url, error); + } + return config; + }); + + 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/apps/live/src/services/page/core.service.ts b/apps/live/src/services/page/core.service.ts new file mode 100644 index 0000000..c3f3242 --- /dev/null +++ b/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/apps/live/src/services/page/extended.service.ts b/apps/live/src/services/page/extended.service.ts new file mode 100644 index 0000000..2b076ef --- /dev/null +++ b/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/apps/live/src/services/page/handler.ts b/apps/live/src/services/page/handler.ts new file mode 100644 index 0000000..2bfd0b1 --- /dev/null +++ b/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/apps/live/src/services/page/project-page.service.ts b/apps/live/src/services/page/project-page.service.ts new file mode 100644 index 0000000..d89ab0a --- /dev/null +++ b/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/apps/live/src/services/pdf-export/effect-utils.ts b/apps/live/src/services/pdf-export/effect-utils.ts new file mode 100644 index 0000000..18f40b0 --- /dev/null +++ b/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/apps/live/src/services/pdf-export/index.ts b/apps/live/src/services/pdf-export/index.ts new file mode 100644 index 0000000..fa2a7c6 --- /dev/null +++ b/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/apps/live/src/services/pdf-export/pdf-export.service.ts b/apps/live/src/services/pdf-export/pdf-export.service.ts new file mode 100644 index 0000000..e9c67fc --- /dev/null +++ b/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/apps/live/src/services/pdf-export/types.ts b/apps/live/src/services/pdf-export/types.ts new file mode 100644 index 0000000..1a95b0e --- /dev/null +++ b/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/apps/live/src/services/user.service.ts b/apps/live/src/services/user.service.ts new file mode 100644 index 0000000..b4c2859 --- /dev/null +++ b/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/apps/live/src/start.ts b/apps/live/src/start.ts new file mode 100644 index 0000000..705eb12 --- /dev/null +++ b/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/apps/live/src/types/admin-commands.ts b/apps/live/src/types/admin-commands.ts new file mode 100644 index 0000000..1cbe7a5 --- /dev/null +++ b/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/apps/live/src/types/index.ts b/apps/live/src/types/index.ts new file mode 100644 index 0000000..39c941d --- /dev/null +++ b/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/apps/live/src/utils/broadcast-error.ts b/apps/live/src/utils/broadcast-error.ts new file mode 100644 index 0000000..4d1077c --- /dev/null +++ b/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/apps/live/src/utils/broadcast-message.ts b/apps/live/src/utils/broadcast-message.ts new file mode 100644 index 0000000..473a3c7 --- /dev/null +++ b/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/apps/live/tests/lib/pdf/pdf-rendering.test.ts b/apps/live/tests/lib/pdf/pdf-rendering.test.ts new file mode 100644 index 0000000..507c6f9 --- /dev/null +++ b/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/apps/live/tests/services/pdf-export/effect-utils.test.ts b/apps/live/tests/services/pdf-export/effect-utils.test.ts new file mode 100644 index 0000000..44ff35e --- /dev/null +++ b/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/apps/live/tsconfig.json b/apps/live/tsconfig.json new file mode 100644 index 0000000..9d49437 --- /dev/null +++ b/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/apps/live/tsdown.config.ts b/apps/live/tsdown.config.ts new file mode 100644 index 0000000..e94a584 --- /dev/null +++ b/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/apps/live/vitest.config.ts b/apps/live/vitest.config.ts new file mode 100644 index 0000000..d9a1624 --- /dev/null +++ b/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/apps/proxy/.prettierignore b/apps/proxy/.prettierignore new file mode 100644 index 0000000..b0b8bc6 --- /dev/null +++ b/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/apps/proxy/Caddyfile.aio.ce b/apps/proxy/Caddyfile.aio.ce new file mode 100644 index 0000000..9cf6d8d --- /dev/null +++ b/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/apps/proxy/Caddyfile.ce b/apps/proxy/Caddyfile.ce new file mode 100644 index 0000000..14559f2 --- /dev/null +++ b/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/apps/proxy/Dockerfile.ce b/apps/proxy/Dockerfile.ce new file mode 100644 index 0000000..87436e7 --- /dev/null +++ b/apps/proxy/Dockerfile.ce @@ -0,0 +1,45 @@ +FROM caddy:2.11.4-builder-alpine AS caddy-builder + +# grpc and otel move together: grpc >= v1.83.0 requires otel v1.44.0, and xcaddy +# resolves these --with pins in one `go get`, so a stale otel pin makes go walk +# grpc backwards until it finds one that fits (silently, with a zero exit) -- +# it lands on the v1.83.0-dev pre-release and the build still succeeds. Bump the +# otel pair in lockstep with grpc, and confirm with `go version -m` afterwards. +RUN XCADDY_SKIP_CLEANUP=1 xcaddy build \ + --with github.com/caddy-dns/cloudflare@v0.2.1 \ + --with github.com/caddy-dns/digitalocean@04bde2867106aa1b44c2f9da41a285fa02e629c5 \ + --with github.com/mholt/caddy-l4@6faae83b167fda94e62b686be5cbeb9b3f8fe002 \ + --with github.com/go-jose/go-jose/v3@v3.0.5 \ + --with github.com/go-jose/go-jose/v4@v4.1.4 \ + --with google.golang.org/grpc@v1.83.1 \ + --with go.opentelemetry.io/otel@v1.44.0 \ + --with go.opentelemetry.io/otel/sdk@v1.44.0 + +# Caddy's own go.mod pins x/crypto, x/net and x/text at versions with open CVEs; +# xcaddy preserved its build dir above, so upgrade them there and rebuild. +# x/crypto v0.55.0 requires x/net v0.57.0 and x/text v0.41.0, hence those floors. +RUN cd "$(find /tmp -maxdepth 1 -name 'buildenv_*' -type d | head -1)" && \ + go get golang.org/x/crypto@v0.55.0 && \ + go get golang.org/x/net@v0.57.0 && \ + go get golang.org/x/text@v0.41.0 && \ + go mod tidy && \ + go build -o /usr/bin/caddy -ldflags "-w -s" -trimpath -tags nobadger,nomysql,nopgx . + +# xcaddy resolves every --with pin in a single `go get`, which walks a module +# backwards to an older release rather than failing when the pins conflict. +# Assert the binary really carries the pinned grpc, so a silent downgrade fails +# the build instead of shipping a proxy that only looks patched. +RUN go version -m /usr/bin/caddy | grep -qE 'google\.golang\.org/grpc[[:space:]]v1\.83\.1[[:space:]]' \ + || { echo "caddy was not built against the pinned grpc -- check the --with pins" >&2; exit 1; } + +FROM caddy:2.11.4-alpine + +# OS security updates. --available forces reinstall from the current repos; +# bump APK_SECURITY_PATCH to bust buildx's cached layer (keyed on this command +# string), otherwise a rebuild silently re-ships the old packages. +ARG APK_SECURITY_PATCH=2026-09-07 +RUN echo "apk-security-patch ${APK_SECURITY_PATCH}" && apk update && apk upgrade --no-cache --available && 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/apps/space/.env.example b/apps/space/.env.example new file mode 100644 index 0000000..59663aa --- /dev/null +++ b/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/apps/space/.gitignore b/apps/space/.gitignore new file mode 100644 index 0000000..5449b29 --- /dev/null +++ b/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/apps/space/.prettierignore b/apps/space/.prettierignore new file mode 100644 index 0000000..b0b8bc6 --- /dev/null +++ b/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/apps/space/Dockerfile.dev b/apps/space/Dockerfile.dev new file mode 100644 index 0000000..20ac137 --- /dev/null +++ b/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/apps/space/Dockerfile.space b/apps/space/Dockerfile.space new file mode 100644 index 0000000..ee5e3c3 --- /dev/null +++ b/apps/space/Dockerfile.space @@ -0,0 +1,114 @@ +FROM node:22-alpine AS base + +WORKDIR /app + +ENV TURBO_TELEMETRY_DISABLED=1 +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH" +ENV CI=1 + +RUN corepack enable pnpm + +# =========================================================================== # + +FROM base AS builder + +RUN pnpm add -g turbo@2.9.18 + +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 + +ENV NODE_ENV=production + +# OS security updates. --available forces reinstall from the current repos; +# bump APK_SECURITY_PATCH to bust buildx's cached layer (keyed on this command +# string), otherwise a rebuild silently re-ships the old packages. +ARG APK_SECURITY_PATCH=2026-09-07 +RUN echo "apk-security-patch ${APK_SECURITY_PATCH}" && apk update && apk upgrade --no-cache --available + +# Remove go from Alpine APK database; not needed at runtime and carries stdlib CVEs +RUN apk del go 2>/dev/null || true + +# npm is not used at runtime (the CMD execs the react-router-serve bin shim +# directly rather than going through npx); its bundled dependencies carry known +# vulnerabilities (tar, brace-expansion, pacote, sigstore, ip-address, fast-uri, +# browserslist), so remove the whole CLI rather than picking off one package at +# a time. +RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx + +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 + +# esbuild and tsgolint are build-only Go binaries; remove from runtime image to eliminate stdlib CVEs +RUN find /app/node_modules \( -name 'esbuild' -o -name 'tsgolint' \) -type f -delete 2>/dev/null || true + +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 ["./node_modules/.bin/react-router-serve", "./build/server/index.js"] diff --git a/apps/space/README.md b/apps/space/README.md new file mode 100644 index 0000000..fc22981 --- /dev/null +++ b/apps/space/README.md @@ -0,0 +1,10 @@ +

+ +

+ + Plane Logo + +

+ +

Plane Space

+

Open-source, self-hosted project planning tool

diff --git a/apps/space/additional.d.ts b/apps/space/additional.d.ts new file mode 100644 index 0000000..f400344 --- /dev/null +++ b/apps/space/additional.d.ts @@ -0,0 +1,2 @@ +// additional.d.ts +/// diff --git a/apps/space/app/[workspaceSlug]/[projectId]/page.tsx b/apps/space/app/[workspaceSlug]/[projectId]/page.tsx new file mode 100644 index 0000000..b14ec17 --- /dev/null +++ b/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/apps/space/app/assets/404.svg b/apps/space/app/assets/404.svg new file mode 100644 index 0000000..4c29841 --- /dev/null +++ b/apps/space/app/assets/404.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/space/app/assets/auth/background-pattern-dark.svg b/apps/space/app/assets/auth/background-pattern-dark.svg new file mode 100644 index 0000000..c258cba --- /dev/null +++ b/apps/space/app/assets/auth/background-pattern-dark.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/space/app/assets/auth/background-pattern.svg b/apps/space/app/assets/auth/background-pattern.svg new file mode 100644 index 0000000..5fcbeec --- /dev/null +++ b/apps/space/app/assets/auth/background-pattern.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/space/app/assets/favicon/apple-touch-icon.png b/apps/space/app/assets/favicon/apple-touch-icon.png new file mode 100644 index 0000000..a631267 Binary files /dev/null and b/apps/space/app/assets/favicon/apple-touch-icon.png differ diff --git a/apps/space/app/assets/favicon/favicon-16x16.png b/apps/space/app/assets/favicon/favicon-16x16.png new file mode 100644 index 0000000..af59ef0 Binary files /dev/null and b/apps/space/app/assets/favicon/favicon-16x16.png differ diff --git a/apps/space/app/assets/favicon/favicon-32x32.png b/apps/space/app/assets/favicon/favicon-32x32.png new file mode 100644 index 0000000..16a1271 Binary files /dev/null and b/apps/space/app/assets/favicon/favicon-32x32.png differ diff --git a/apps/space/app/assets/favicon/favicon.ico b/apps/space/app/assets/favicon/favicon.ico new file mode 100644 index 0000000..613b1a3 Binary files /dev/null and b/apps/space/app/assets/favicon/favicon.ico differ diff --git a/apps/space/app/assets/favicon/site.webmanifest b/apps/space/app/assets/favicon/site.webmanifest new file mode 100644 index 0000000..1d41057 --- /dev/null +++ b/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/apps/space/app/assets/images/logo-spinner-dark.gif b/apps/space/app/assets/images/logo-spinner-dark.gif new file mode 100644 index 0000000..8bd0832 Binary files /dev/null and b/apps/space/app/assets/images/logo-spinner-dark.gif differ diff --git a/apps/space/app/assets/images/logo-spinner-light.gif b/apps/space/app/assets/images/logo-spinner-light.gif new file mode 100644 index 0000000..8b57103 Binary files /dev/null and b/apps/space/app/assets/images/logo-spinner-light.gif differ diff --git a/apps/space/app/assets/instance/instance-failure-dark.svg b/apps/space/app/assets/instance/instance-failure-dark.svg new file mode 100644 index 0000000..58d6917 --- /dev/null +++ b/apps/space/app/assets/instance/instance-failure-dark.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/space/app/assets/instance/instance-failure.svg b/apps/space/app/assets/instance/instance-failure.svg new file mode 100644 index 0000000..a598622 --- /dev/null +++ b/apps/space/app/assets/instance/instance-failure.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/space/app/assets/instance/intake-sent-dark.png b/apps/space/app/assets/instance/intake-sent-dark.png new file mode 100644 index 0000000..70a6273 Binary files /dev/null and b/apps/space/app/assets/instance/intake-sent-dark.png differ diff --git a/apps/space/app/assets/instance/intake-sent-light.png b/apps/space/app/assets/instance/intake-sent-light.png new file mode 100644 index 0000000..9425c07 Binary files /dev/null and b/apps/space/app/assets/instance/intake-sent-light.png differ diff --git a/apps/space/app/assets/instance/plane-instance-not-ready.webp b/apps/space/app/assets/instance/plane-instance-not-ready.webp new file mode 100644 index 0000000..a0efca5 Binary files /dev/null and b/apps/space/app/assets/instance/plane-instance-not-ready.webp differ diff --git a/apps/space/app/assets/instance/plane-takeoff.png b/apps/space/app/assets/instance/plane-takeoff.png new file mode 100644 index 0000000..417ff82 Binary files /dev/null and b/apps/space/app/assets/instance/plane-takeoff.png differ diff --git a/apps/space/app/assets/logos/gitea-logo.svg b/apps/space/app/assets/logos/gitea-logo.svg new file mode 100644 index 0000000..4329134 --- /dev/null +++ b/apps/space/app/assets/logos/gitea-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/space/app/assets/logos/github-black.png b/apps/space/app/assets/logos/github-black.png new file mode 100644 index 0000000..7a7a824 Binary files /dev/null and b/apps/space/app/assets/logos/github-black.png differ diff --git a/apps/space/app/assets/logos/github-dark.svg b/apps/space/app/assets/logos/github-dark.svg new file mode 100644 index 0000000..a0cb35c --- /dev/null +++ b/apps/space/app/assets/logos/github-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/space/app/assets/logos/github-square.svg b/apps/space/app/assets/logos/github-square.svg new file mode 100644 index 0000000..a7836db --- /dev/null +++ b/apps/space/app/assets/logos/github-square.svg @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/apps/space/app/assets/logos/github-white.svg b/apps/space/app/assets/logos/github-white.svg new file mode 100644 index 0000000..90fe34d --- /dev/null +++ b/apps/space/app/assets/logos/github-white.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/apps/space/app/assets/logos/gitlab-logo.svg b/apps/space/app/assets/logos/gitlab-logo.svg new file mode 100644 index 0000000..dab4d8b --- /dev/null +++ b/apps/space/app/assets/logos/gitlab-logo.svg @@ -0,0 +1 @@ + diff --git a/apps/space/app/assets/logos/google-logo.svg b/apps/space/app/assets/logos/google-logo.svg new file mode 100644 index 0000000..088288f --- /dev/null +++ b/apps/space/app/assets/logos/google-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/space/app/assets/plane-logo.svg b/apps/space/app/assets/plane-logo.svg new file mode 100644 index 0000000..1117941 --- /dev/null +++ b/apps/space/app/assets/plane-logo.svg @@ -0,0 +1,94 @@ + + + + diff --git a/apps/space/app/assets/plane-logos/black-horizontal-with-blue-logo.png b/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/apps/space/app/assets/plane-logos/black-horizontal-with-blue-logo.png differ diff --git a/apps/space/app/assets/plane-logos/blue-without-text-new.png b/apps/space/app/assets/plane-logos/blue-without-text-new.png new file mode 100644 index 0000000..ea94aec Binary files /dev/null and b/apps/space/app/assets/plane-logos/blue-without-text-new.png differ diff --git a/apps/space/app/assets/plane-logos/blue-without-text.png b/apps/space/app/assets/plane-logos/blue-without-text.png new file mode 100644 index 0000000..ea94aec Binary files /dev/null and b/apps/space/app/assets/plane-logos/blue-without-text.png differ diff --git a/apps/space/app/assets/plane-logos/white-horizontal-with-blue-logo.png b/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/apps/space/app/assets/plane-logos/white-horizontal-with-blue-logo.png differ diff --git a/apps/space/app/assets/plane-logos/white-horizontal.svg b/apps/space/app/assets/plane-logos/white-horizontal.svg new file mode 100644 index 0000000..13e2dbb --- /dev/null +++ b/apps/space/app/assets/plane-logos/white-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/space/app/assets/project-not-published.svg b/apps/space/app/assets/project-not-published.svg new file mode 100644 index 0000000..db4b404 --- /dev/null +++ b/apps/space/app/assets/project-not-published.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/space/app/assets/robots.txt b/apps/space/app/assets/robots.txt new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/apps/space/app/assets/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/apps/space/app/assets/something-went-wrong.svg b/apps/space/app/assets/something-went-wrong.svg new file mode 100644 index 0000000..bd51f7f --- /dev/null +++ b/apps/space/app/assets/something-went-wrong.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/space/app/assets/user-logged-in.svg b/apps/space/app/assets/user-logged-in.svg new file mode 100644 index 0000000..e20b49e --- /dev/null +++ b/apps/space/app/assets/user-logged-in.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/space/app/compat/next/helper.ts b/apps/space/app/compat/next/helper.ts new file mode 100644 index 0000000..c4edf3d --- /dev/null +++ b/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/apps/space/app/compat/next/navigation.ts b/apps/space/app/compat/next/navigation.ts new file mode 100644 index 0000000..0aa9254 --- /dev/null +++ b/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/apps/space/app/entry.client.tsx b/apps/space/app/entry.client.tsx new file mode 100644 index 0000000..36c91ca --- /dev/null +++ b/apps/space/app/entry.client.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 { initPromise } from "@plane/i18n"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { HydratedRouter } from "react-router/dom"; + +// Initialize i18n before hydrating (the remix-i18next pattern for React +// Router: await init, then hydrateRoot). The server renders with an +// initialized instance; hydrating before the client instance is ready would +// make the first client render diverge from the server HTML, and React 19 +// leaves server DOM it could not adopt in place instead of clearing it. +void initPromise + .catch(() => {}) + .then(() => { + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); + }); diff --git a/apps/space/app/error.tsx b/apps/space/app/error.tsx new file mode 100644 index 0000000..87aa8dc --- /dev/null +++ b/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/apps/space/app/issues/[anchor]/layout.tsx b/apps/space/app/issues/[anchor]/layout.tsx new file mode 100644 index 0000000..6dbc2e7 --- /dev/null +++ b/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/apps/space/app/issues/[anchor]/page.tsx b/apps/space/app/issues/[anchor]/page.tsx new file mode 100644 index 0000000..cd19bda --- /dev/null +++ b/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/apps/space/app/not-found.tsx b/apps/space/app/not-found.tsx new file mode 100644 index 0000000..e137fdd --- /dev/null +++ b/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/apps/space/app/page.tsx b/apps/space/app/page.tsx new file mode 100644 index 0000000..02a9a56 --- /dev/null +++ b/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/apps/space/app/providers.tsx b/apps/space/app/providers.tsx new file mode 100644 index 0000000..463770e --- /dev/null +++ b/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/apps/space/app/root.tsx b/apps/space/app/root.tsx new file mode 100644 index 0000000..fe504b1 --- /dev/null +++ b/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/apps/space/app/routes.ts b/apps/space/app/routes.ts new file mode 100644 index 0000000..1a94ca0 --- /dev/null +++ b/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/apps/space/app/types/next-link.d.ts b/apps/space/app/types/next-link.d.ts new file mode 100644 index 0000000..e39cc52 --- /dev/null +++ b/apps/space/app/types/next-link.d.ts @@ -0,0 +1,14 @@ +declare module "next/link" { + import type { FC, ComponentProps } from "react"; + + type NextLinkProps = ComponentProps<"a"> & { + href: string; + replace?: boolean; + prefetch?: boolean; + scroll?: boolean; + shallow?: boolean; + }; + + const Link: FC; + export default Link; +} diff --git a/apps/space/app/types/next-navigation.d.ts b/apps/space/app/types/next-navigation.d.ts new file mode 100644 index 0000000..67a80c4 --- /dev/null +++ b/apps/space/app/types/next-navigation.d.ts @@ -0,0 +1,14 @@ +declare module "next/navigation" { + export function useRouter(): { + push: (url: string) => void; + replace: (url: string) => void; + back: () => void; + forward: () => void; + refresh: () => void; + prefetch: (url: string) => Promise; + }; + + export function usePathname(): string; + export function useSearchParams(): URLSearchParams; + export function useParams>(): T; +} diff --git a/apps/space/app/types/react-router-virtual.d.ts b/apps/space/app/types/react-router-virtual.d.ts new file mode 100644 index 0000000..68caf20 --- /dev/null +++ b/apps/space/app/types/react-router-virtual.d.ts @@ -0,0 +1,5 @@ +declare module "virtual:react-router/server-build" { + import type { ServerBuild } from "react-router"; + const serverBuild: ServerBuild; + export default serverBuild; +} diff --git a/apps/space/components/account/auth-forms/auth-banner.tsx b/apps/space/components/account/auth-forms/auth-banner.tsx new file mode 100644 index 0000000..e69e8ae --- /dev/null +++ b/apps/space/components/account/auth-forms/auth-banner.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 { CloseOutline, InfoOutline } from "@makeplane/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/apps/space/components/account/auth-forms/auth-header.tsx b/apps/space/components/account/auth-forms/auth-header.tsx new file mode 100644 index 0000000..a2063fe --- /dev/null +++ b/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/apps/space/components/account/auth-forms/auth-root.tsx b/apps/space/components/account/auth-forms/auth-root.tsx new file mode 100644 index 0000000..d31649f --- /dev/null +++ b/apps/space/components/account/auth-forms/auth-root.tsx @@ -0,0 +1,192 @@ +/** + * 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 ( + [ + 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/apps/space/components/account/auth-forms/email.tsx b/apps/space/components/account/auth-forms/email.tsx new file mode 100644 index 0000000..997c3df --- /dev/null +++ b/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 { CloseCircleOutline, WarningCircleOutline } from "@makeplane/propel/icons"; +// 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/apps/space/components/account/auth-forms/index.ts b/apps/space/components/account/auth-forms/index.ts new file mode 100644 index 0000000..125f669 --- /dev/null +++ b/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/apps/space/components/account/auth-forms/password.tsx b/apps/space/components/account/auth-forms/password.tsx new file mode 100644 index 0000000..3b17cdf --- /dev/null +++ b/apps/space/components/account/auth-forms/password.tsx @@ -0,0 +1,268 @@ +/** + * 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 { CloseCircleOutline, HideOutline, ShowOutline } from "@makeplane/propel/icons"; +// 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 ? ( + + ) : ( + + )} +
+ {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 ? ( + + ) : ( + + )} +
+ {!!passwordFormData.confirm_password && + passwordFormData.password !== passwordFormData.confirm_password && + renderPasswordMatchError && Passwords don{"'"}t match} +
+ )} + +
+ {mode === EAuthModes.SIGN_IN ? ( + <> + + {isSMTPConfigured && ( + + )} + + ) : ( + + )} +
+
+ ); +}); diff --git a/apps/space/components/account/auth-forms/unique-code.tsx b/apps/space/components/account/auth-forms/unique-code.tsx new file mode 100644 index 0000000..61f0587 --- /dev/null +++ b/apps/space/components/account/auth-forms/unique-code.tsx @@ -0,0 +1,162 @@ +/** + * 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 { CloseCircleOutline, TickCircleOutline } from "@makeplane/propel/icons"; +// 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/apps/space/components/account/terms-and-conditions.tsx b/apps/space/components/account/terms-and-conditions.tsx new file mode 100644 index 0000000..1e9a71a --- /dev/null +++ b/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/apps/space/components/account/user-logged-in.tsx b/apps/space/components/account/user-logged-in.tsx new file mode 100644 index 0000000..42815b0 --- /dev/null +++ b/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/apps/space/components/common/logo-spinner.tsx b/apps/space/components/common/logo-spinner.tsx new file mode 100644 index 0000000..3f0f179 --- /dev/null +++ b/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/apps/space/components/common/powered-by.tsx b/apps/space/components/common/powered-by.tsx new file mode 100644 index 0000000..05da512 --- /dev/null +++ b/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/apps/space/components/common/project-logo.tsx b/apps/space/components/common/project-logo.tsx new file mode 100644 index 0000000..b9540c0 --- /dev/null +++ b/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/apps/space/components/editor/embeds/mentions/index.ts b/apps/space/components/editor/embeds/mentions/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/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/apps/space/components/editor/embeds/mentions/root.tsx b/apps/space/components/editor/embeds/mentions/root.tsx new file mode 100644 index 0000000..2c6914a --- /dev/null +++ b/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/apps/space/components/editor/embeds/mentions/user.tsx b/apps/space/components/editor/embeds/mentions/user.tsx new file mode 100644 index 0000000..24c817d --- /dev/null +++ b/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/apps/space/components/editor/lite-text-editor.tsx b/apps/space/components/editor/lite-text-editor.tsx new file mode 100644 index 0000000..ecd08da --- /dev/null +++ b/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/apps/space/components/editor/rich-text-editor.tsx b/apps/space/components/editor/rich-text-editor.tsx new file mode 100644 index 0000000..b7846e4 --- /dev/null +++ b/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/apps/space/components/editor/toolbar.tsx b/apps/space/components/editor/toolbar.tsx new file mode 100644 index 0000000..ce2a0a2 --- /dev/null +++ b/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/apps/space/components/instance/instance-failure-view.tsx b/apps/space/components/instance/instance-failure-view.tsx new file mode 100644 index 0000000..2bf810e --- /dev/null +++ b/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/apps/space/components/issues/filters/applied-filters/filters-list.tsx b/apps/space/components/issues/filters/applied-filters/filters-list.tsx new file mode 100644 index 0000000..701fb49 --- /dev/null +++ b/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 { CloseOutline } from "@makeplane/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/apps/space/components/issues/filters/applied-filters/label.tsx b/apps/space/components/issues/filters/applied-filters/label.tsx new file mode 100644 index 0000000..23785b5 --- /dev/null +++ b/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 { CloseOutline } from "@makeplane/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/apps/space/components/issues/filters/applied-filters/priority.tsx b/apps/space/components/issues/filters/applied-filters/priority.tsx new file mode 100644 index 0000000..1b3a03a --- /dev/null +++ b/apps/space/components/issues/filters/applied-filters/priority.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 { PriorityIcon } from "@plane/propel/icons"; +import { CloseOutline } from "@makeplane/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/apps/space/components/issues/filters/applied-filters/root.tsx b/apps/space/components/issues/filters/applied-filters/root.tsx new file mode 100644 index 0000000..58ae481 --- /dev/null +++ b/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/apps/space/components/issues/filters/applied-filters/state.tsx b/apps/space/components/issues/filters/applied-filters/state.tsx new file mode 100644 index 0000000..1a37861 --- /dev/null +++ b/apps/space/components/issues/filters/applied-filters/state.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"; +// plane imports +import { EIconSize } from "@plane/constants"; +import { StateGroupIcon } from "@plane/propel/icons"; +import { CloseOutline } from "@makeplane/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/apps/space/components/issues/filters/helpers/dropdown.tsx b/apps/space/components/issues/filters/helpers/dropdown.tsx new file mode 100644 index 0000000..17f7ca6 --- /dev/null +++ b/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/apps/space/components/issues/filters/helpers/filter-header.tsx b/apps/space/components/issues/filters/helpers/filter-header.tsx new file mode 100644 index 0000000..bd6c677 --- /dev/null +++ b/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 { ChevronDownOutline, ChevronUpOutline } from "@makeplane/propel/icons"; + +interface IFilterHeader { + title: string; + isPreviewEnabled: boolean; + handleIsPreviewEnabled: () => void; +} + +export function FilterHeader({ title, isPreviewEnabled, handleIsPreviewEnabled }: IFilterHeader) { + return ( +
+
{title}
+ +
+ ); +} diff --git a/apps/space/components/issues/filters/helpers/filter-option.tsx b/apps/space/components/issues/filters/helpers/filter-option.tsx new file mode 100644 index 0000000..58d0be5 --- /dev/null +++ b/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 { TickOutline } from "@makeplane/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/apps/space/components/issues/filters/index.ts b/apps/space/components/issues/filters/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/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/apps/space/components/issues/filters/labels.tsx b/apps/space/components/issues/filters/labels.tsx new file mode 100644 index 0000000..db235c5 --- /dev/null +++ b/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/apps/space/components/issues/filters/priority.tsx b/apps/space/components/issues/filters/priority.tsx new file mode 100644 index 0000000..06e4a53 --- /dev/null +++ b/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/apps/space/components/issues/filters/root.tsx b/apps/space/components/issues/filters/root.tsx new file mode 100644 index 0000000..983f00a --- /dev/null +++ b/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/apps/space/components/issues/filters/selection.tsx b/apps/space/components/issues/filters/selection.tsx new file mode 100644 index 0000000..6781c52 --- /dev/null +++ b/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 { CloseOutline, SearchOutline } from "@makeplane/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/apps/space/components/issues/filters/state.tsx b/apps/space/components/issues/filters/state.tsx new file mode 100644 index 0000000..f9275a2 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/error.tsx b/apps/space/components/issues/issue-layouts/error.tsx new file mode 100644 index 0000000..40e4f55 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/index.ts b/apps/space/components/issues/issue-layouts/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/issue-layout-HOC.tsx b/apps/space/components/issues/issue-layouts/issue-layout-HOC.tsx new file mode 100644 index 0000000..6a9a719 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/apps/space/components/issues/issue-layouts/kanban/base-kanban-root.tsx new file mode 100644 index 0000000..c38a577 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/kanban/block-reactions.tsx b/apps/space/components/issues/issue-layouts/kanban/block-reactions.tsx new file mode 100644 index 0000000..78e16a4 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/kanban/block.tsx b/apps/space/components/issues/issue-layouts/kanban/block.tsx new file mode 100644 index 0000000..3ad4c71 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/kanban/blocks-list.tsx b/apps/space/components/issues/issue-layouts/kanban/blocks-list.tsx new file mode 100644 index 0000000..319bdf7 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/kanban/default.tsx b/apps/space/components/issues/issue-layouts/kanban/default.tsx new file mode 100644 index 0000000..fa934ae --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/apps/space/components/issues/issue-layouts/kanban/headers/group-by-card.tsx new file mode 100644 index 0000000..b66127c --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx b/apps/space/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx new file mode 100644 index 0000000..9040116 --- /dev/null +++ b/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 { ChevronDownOutline, ChevronUpOutline } from "@makeplane/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/apps/space/components/issues/issue-layouts/kanban/kanban-group.tsx b/apps/space/components/issues/issue-layouts/kanban/kanban-group.tsx new file mode 100644 index 0000000..360b5e9 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/kanban/swimlanes.tsx b/apps/space/components/issues/issue-layouts/kanban/swimlanes.tsx new file mode 100644 index 0000000..977bf5b --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/list/base-list-root.tsx b/apps/space/components/issues/issue-layouts/list/base-list-root.tsx new file mode 100644 index 0000000..fbc1347 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/list/block.tsx b/apps/space/components/issues/issue-layouts/list/block.tsx new file mode 100644 index 0000000..24fc5f2 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/list/blocks-list.tsx b/apps/space/components/issues/issue-layouts/list/blocks-list.tsx new file mode 100644 index 0000000..2580afb --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/list/default.tsx b/apps/space/components/issues/issue-layouts/list/default.tsx new file mode 100644 index 0000000..f8f545b --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/list/headers/group-by-card.tsx b/apps/space/components/issues/issue-layouts/list/headers/group-by-card.tsx new file mode 100644 index 0000000..5f37484 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/list/list-group.tsx b/apps/space/components/issues/issue-layouts/list/list-group.tsx new file mode 100644 index 0000000..26bc499 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/properties/all-properties.tsx b/apps/space/components/issues/issue-layouts/properties/all-properties.tsx new file mode 100644 index 0000000..728ccd4 --- /dev/null +++ b/apps/space/components/issues/issue-layouts/properties/all-properties.tsx @@ -0,0 +1,184 @@ +/** + * 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 { AttachOutline, LinkOutline, ViewsOutline } from "@makeplane/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/apps/space/components/issues/issue-layouts/properties/cycle.tsx b/apps/space/components/issues/issue-layouts/properties/cycle.tsx new file mode 100644 index 0000000..7f67aee --- /dev/null +++ b/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 { CyclesOutline } from "@makeplane/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/apps/space/components/issues/issue-layouts/properties/due-date.tsx b/apps/space/components/issues/issue-layouts/properties/due-date.tsx new file mode 100644 index 0000000..b3d291c --- /dev/null +++ b/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 { DueDateOutline } from "@makeplane/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/apps/space/components/issues/issue-layouts/properties/labels.tsx b/apps/space/components/issues/issue-layouts/properties/labels.tsx new file mode 100644 index 0000000..909c716 --- /dev/null +++ b/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 { LabelsOutline } from "@makeplane/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/apps/space/components/issues/issue-layouts/properties/member.tsx b/apps/space/components/issues/issue-layouts/properties/member.tsx new file mode 100644 index 0000000..c6c5cc3 --- /dev/null +++ b/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 { MembersOutline } from "@makeplane/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/apps/space/components/issues/issue-layouts/properties/modules.tsx b/apps/space/components/issues/issue-layouts/properties/modules.tsx new file mode 100644 index 0000000..bd610a4 --- /dev/null +++ b/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 { ModuleOutline } from "@makeplane/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/apps/space/components/issues/issue-layouts/properties/priority.tsx b/apps/space/components/issues/issue-layouts/properties/priority.tsx new file mode 100644 index 0000000..9b4dc3d --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/properties/state.tsx b/apps/space/components/issues/issue-layouts/properties/state.tsx new file mode 100644 index 0000000..de15969 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/root.tsx b/apps/space/components/issues/issue-layouts/root.tsx new file mode 100644 index 0000000..1b8f985 --- /dev/null +++ b/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/apps/space/components/issues/issue-layouts/utils.tsx b/apps/space/components/issues/issue-layouts/utils.tsx new file mode 100644 index 0000000..116ceee --- /dev/null +++ b/apps/space/components/issues/issue-layouts/utils.tsx @@ -0,0 +1,242 @@ +/** + * 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, PriorityIcon, StateGroupIcon } from "@plane/propel/icons"; +import { CyclesOutline, ModuleOutline } from "@makeplane/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/apps/space/components/issues/issue-layouts/with-display-properties-HOC.tsx b/apps/space/components/issues/issue-layouts/with-display-properties-HOC.tsx new file mode 100644 index 0000000..bd6a665 --- /dev/null +++ b/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/apps/space/components/issues/navbar/controls.tsx b/apps/space/components/issues/navbar/controls.tsx new file mode 100644 index 0000000..f5902dd --- /dev/null +++ b/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/apps/space/components/issues/navbar/index.ts b/apps/space/components/issues/navbar/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/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/apps/space/components/issues/navbar/layout-icon.tsx b/apps/space/components/issues/navbar/layout-icon.tsx new file mode 100644 index 0000000..d3951d4 --- /dev/null +++ b/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 { BoardOutline, ListOutline } from "@makeplane/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/apps/space/components/issues/navbar/layout-selection.tsx b/apps/space/components/issues/navbar/layout-selection.tsx new file mode 100644 index 0000000..927fd7d --- /dev/null +++ b/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/apps/space/components/issues/navbar/root.tsx b/apps/space/components/issues/navbar/root.tsx new file mode 100644 index 0000000..7a383f8 --- /dev/null +++ b/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 { ProjectsOutline } from "@makeplane/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/apps/space/components/issues/navbar/theme.tsx b/apps/space/components/issues/navbar/theme.tsx new file mode 100644 index 0000000..3a362ae --- /dev/null +++ b/apps/space/components/issues/navbar/theme.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 { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +import { AppearanceOutline, MoonOutline } from "@makeplane/propel/icons"; + +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/apps/space/components/issues/navbar/user-avatar.tsx b/apps/space/components/issues/navbar/user-avatar.tsx new file mode 100644 index 0000000..c3557fd --- /dev/null +++ b/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 { LogOutOutline } from "@makeplane/propel/icons"; +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/apps/space/components/issues/peek-overview/comment/add-comment.tsx b/apps/space/components/issues/peek-overview/comment/add-comment.tsx new file mode 100644 index 0000000..acd86cd --- /dev/null +++ b/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/apps/space/components/issues/peek-overview/comment/comment-detail-card.tsx b/apps/space/components/issues/peek-overview/comment/comment-detail-card.tsx new file mode 100644 index 0000000..7a1aa65 --- /dev/null +++ b/apps/space/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -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 React, { useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { ChatOutline, CloseOutline, MoreVerticalOutline, TickOutline } from "@makeplane/propel/icons"; +import { Menu, Transition } from "@headlessui/react"; +// plane imports +import type { EditorRefApi } from "@plane/editor"; +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/apps/space/components/issues/peek-overview/comment/comment-reactions.tsx b/apps/space/components/issues/peek-overview/comment/comment-reactions.tsx new file mode 100644 index 0000000..be6a6fb --- /dev/null +++ b/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/apps/space/components/issues/peek-overview/full-screen-peek-view.tsx b/apps/space/components/issues/peek-overview/full-screen-peek-view.tsx new file mode 100644 index 0000000..4cf425b --- /dev/null +++ b/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/apps/space/components/issues/peek-overview/header.tsx b/apps/space/components/issues/peek-overview/header.tsx new file mode 100644 index 0000000..7543ecc --- /dev/null +++ b/apps/space/components/issues/peek-overview/header.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 React from "react"; +import { observer } from "mobx-react"; +import { + ArrowNarrowRightOutline, + FullScreenPeekOutline, + LinkOutline, + ModalPeekOutline, + SidePeekOutline, +} from "@makeplane/propel/icons"; +import { Listbox, Transition } from "@headlessui/react"; +// ui +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: SidePeekOutline, label: "Side Peek" }, + { + key: "modal", + icon: ModalPeekOutline, + label: "Modal", + }, + { + key: "full", + icon: FullScreenPeekOutline, + 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 ?? SidePeekOutline; + + 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/apps/space/components/issues/peek-overview/index.ts b/apps/space/components/issues/peek-overview/index.ts new file mode 100644 index 0000000..d0bf324 --- /dev/null +++ b/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/apps/space/components/issues/peek-overview/issue-activity.tsx b/apps/space/components/issues/peek-overview/issue-activity.tsx new file mode 100644 index 0000000..3519dc5 --- /dev/null +++ b/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 { LockOutline } from "@makeplane/propel/icons"; +// 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/apps/space/components/issues/peek-overview/issue-details.tsx b/apps/space/components/issues/peek-overview/issue-details.tsx new file mode 100644 index 0000000..51139b4 --- /dev/null +++ b/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/apps/space/components/issues/peek-overview/issue-properties.tsx b/apps/space/components/issues/peek-overview/issue-properties.tsx new file mode 100644 index 0000000..2e5eb5c --- /dev/null +++ b/apps/space/components/issues/peek-overview/issue-properties.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 { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { StateGroupIcon, PriorityIcon } from "@plane/propel/icons"; +import { DueDateOutline, LinkOutline, PriorityOutline, StateOutline } from "@makeplane/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/apps/space/components/issues/peek-overview/issue-reaction.tsx b/apps/space/components/issues/peek-overview/issue-reaction.tsx new file mode 100644 index 0000000..2b847d4 --- /dev/null +++ b/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/apps/space/components/issues/peek-overview/layout.tsx b/apps/space/components/issues/peek-overview/layout.tsx new file mode 100644 index 0000000..ebde720 --- /dev/null +++ b/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/apps/space/components/issues/peek-overview/side-peek-view.tsx b/apps/space/components/issues/peek-overview/side-peek-view.tsx new file mode 100644 index 0000000..aa981d8 --- /dev/null +++ b/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/apps/space/components/issues/reactions/issue-emoji-reactions.tsx b/apps/space/components/issues/reactions/issue-emoji-reactions.tsx new file mode 100644 index 0000000..7727ec9 --- /dev/null +++ b/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/apps/space/components/issues/reactions/issue-vote-reactions.tsx b/apps/space/components/issues/reactions/issue-vote-reactions.tsx new file mode 100644 index 0000000..7dd41d3 --- /dev/null +++ b/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 { ArrowDownOutline, TopArrowOutline } from "@makeplane/propel/icons"; +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/apps/space/components/ui/not-found.tsx b/apps/space/components/ui/not-found.tsx new file mode 100644 index 0000000..e3952f5 --- /dev/null +++ b/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/apps/space/components/views/auth.tsx b/apps/space/components/views/auth.tsx new file mode 100644 index 0000000..11591ea --- /dev/null +++ b/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/apps/space/components/views/header.tsx b/apps/space/components/views/header.tsx new file mode 100644 index 0000000..dc2dabe --- /dev/null +++ b/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/apps/space/components/views/index.ts b/apps/space/components/views/index.ts new file mode 100644 index 0000000..68908cd --- /dev/null +++ b/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/apps/space/helpers/authentication.helper.tsx b/apps/space/helpers/authentication.helper.tsx new file mode 100644 index 0000000..6e11d61 --- /dev/null +++ b/apps/space/helpers/authentication.helper.tsx @@ -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 { 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", + 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 + BOT_USER_LOGIN_FORBIDDEN = "5017", + 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.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.BOT_USER_LOGIN_FORBIDDEN]: { + title: `Sign in not allowed`, + message: () => `This account cannot be used to sign in. Please use a personal account.`, + }, + [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.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.BOT_USER_LOGIN_FORBIDDEN, + 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/apps/space/helpers/common.helper.ts b/apps/space/helpers/common.helper.ts new file mode 100644 index 0000000..f11f634 --- /dev/null +++ b/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/apps/space/helpers/date-time.helper.ts b/apps/space/helpers/date-time.helper.ts new file mode 100644 index 0000000..eb6ce9b --- /dev/null +++ b/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/apps/space/helpers/editor.helper.ts b/apps/space/helpers/editor.helper.ts new file mode 100644 index 0000000..26c787d --- /dev/null +++ b/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/apps/space/helpers/emoji.helper.tsx b/apps/space/helpers/emoji.helper.tsx new file mode 100644 index 0000000..12d359a --- /dev/null +++ b/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/apps/space/helpers/file.helper.ts b/apps/space/helpers/file.helper.ts new file mode 100644 index 0000000..1a5947e --- /dev/null +++ b/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/apps/space/helpers/issue.helper.ts b/apps/space/helpers/issue.helper.ts new file mode 100644 index 0000000..aa11034 --- /dev/null +++ b/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/apps/space/helpers/query-param-generator.ts b/apps/space/helpers/query-param-generator.ts new file mode 100644 index 0000000..6f2ffcd --- /dev/null +++ b/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/apps/space/helpers/state.helper.ts b/apps/space/helpers/state.helper.ts new file mode 100644 index 0000000..8a701dc --- /dev/null +++ b/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/apps/space/helpers/string.helper.ts b/apps/space/helpers/string.helper.ts new file mode 100644 index 0000000..674a4ba --- /dev/null +++ b/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/apps/space/hooks/oauth/core.tsx b/apps/space/hooks/oauth/core.tsx new file mode 100644 index 0000000..63a18cc --- /dev/null +++ b/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/apps/space/hooks/oauth/extended.tsx b/apps/space/hooks/oauth/extended.tsx new file mode 100644 index 0000000..9e7ca61 --- /dev/null +++ b/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/apps/space/hooks/oauth/index.ts b/apps/space/hooks/oauth/index.ts new file mode 100644 index 0000000..e88c743 --- /dev/null +++ b/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/apps/space/hooks/store/publish/index.ts b/apps/space/hooks/store/publish/index.ts new file mode 100644 index 0000000..6b56541 --- /dev/null +++ b/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/apps/space/hooks/store/publish/use-publish-list.ts b/apps/space/hooks/store/publish/use-publish-list.ts new file mode 100644 index 0000000..8a731bd --- /dev/null +++ b/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/apps/space/hooks/store/publish/use-publish.ts b/apps/space/hooks/store/publish/use-publish.ts new file mode 100644 index 0000000..50ea527 --- /dev/null +++ b/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/apps/space/hooks/store/use-cycle.ts b/apps/space/hooks/store/use-cycle.ts new file mode 100644 index 0000000..4b7e9e4 --- /dev/null +++ b/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/apps/space/hooks/store/use-instance.ts b/apps/space/hooks/store/use-instance.ts new file mode 100644 index 0000000..3f783a9 --- /dev/null +++ b/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/apps/space/hooks/store/use-issue-details.tsx b/apps/space/hooks/store/use-issue-details.tsx new file mode 100644 index 0000000..a30403e --- /dev/null +++ b/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/apps/space/hooks/store/use-issue-filter.ts b/apps/space/hooks/store/use-issue-filter.ts new file mode 100644 index 0000000..1f60bae --- /dev/null +++ b/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/apps/space/hooks/store/use-issue.ts b/apps/space/hooks/store/use-issue.ts new file mode 100644 index 0000000..808117e --- /dev/null +++ b/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/apps/space/hooks/store/use-label.ts b/apps/space/hooks/store/use-label.ts new file mode 100644 index 0000000..f7c2618 --- /dev/null +++ b/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/apps/space/hooks/store/use-member.ts b/apps/space/hooks/store/use-member.ts new file mode 100644 index 0000000..82443db --- /dev/null +++ b/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/apps/space/hooks/store/use-module.ts b/apps/space/hooks/store/use-module.ts new file mode 100644 index 0000000..c2d5768 --- /dev/null +++ b/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/apps/space/hooks/store/use-state.ts b/apps/space/hooks/store/use-state.ts new file mode 100644 index 0000000..8f23977 --- /dev/null +++ b/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/apps/space/hooks/store/use-user-profile.ts b/apps/space/hooks/store/use-user-profile.ts new file mode 100644 index 0000000..ec53da9 --- /dev/null +++ b/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/apps/space/hooks/store/use-user.ts b/apps/space/hooks/store/use-user.ts new file mode 100644 index 0000000..b3af67a --- /dev/null +++ b/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/apps/space/hooks/use-clipboard-write-permission.tsx b/apps/space/hooks/use-clipboard-write-permission.tsx new file mode 100644 index 0000000..8b1bf16 --- /dev/null +++ b/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/apps/space/hooks/use-editor-flagging.ts b/apps/space/hooks/use-editor-flagging.ts new file mode 100644 index 0000000..88e82f7 --- /dev/null +++ b/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/apps/space/hooks/use-intersection-observer.tsx b/apps/space/hooks/use-intersection-observer.tsx new file mode 100644 index 0000000..4cf9a27 --- /dev/null +++ b/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/apps/space/hooks/use-is-in-iframe.tsx b/apps/space/hooks/use-is-in-iframe.tsx new file mode 100644 index 0000000..3fb5b55 --- /dev/null +++ b/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/apps/space/hooks/use-mention.tsx b/apps/space/hooks/use-mention.tsx new file mode 100644 index 0000000..1d84cd7 --- /dev/null +++ b/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(undefined); + + 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/apps/space/hooks/use-parse-editor-content.ts b/apps/space/hooks/use-parse-editor-content.ts new file mode 100644 index 0000000..ec13194 --- /dev/null +++ b/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/apps/space/hooks/use-timer.tsx b/apps/space/hooks/use-timer.tsx new file mode 100644 index 0000000..c663d80 --- /dev/null +++ b/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/apps/space/lib/b-progress/AppProgressBar.tsx b/apps/space/lib/b-progress/AppProgressBar.tsx new file mode 100644 index 0000000..e436258 --- /dev/null +++ b/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/apps/space/lib/b-progress/index.tsx b/apps/space/lib/b-progress/index.tsx new file mode 100644 index 0000000..5920172 --- /dev/null +++ b/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/apps/space/lib/instance-provider.tsx b/apps/space/lib/instance-provider.tsx new file mode 100644 index 0000000..bd18f81 --- /dev/null +++ b/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/apps/space/lib/store-provider.tsx b/apps/space/lib/store-provider.tsx new file mode 100644 index 0000000..2987590 --- /dev/null +++ b/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/apps/space/lib/toast-provider.tsx b/apps/space/lib/toast-provider.tsx new file mode 100644 index 0000000..420c801 --- /dev/null +++ b/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/apps/space/nginx/nginx.conf b/apps/space/nginx/nginx.conf new file mode 100644 index 0000000..e6f15b1 --- /dev/null +++ b/apps/space/nginx/nginx.conf @@ -0,0 +1,30 @@ +worker_processes 4; + +events { + worker_connections 1024; +} + +http { + include mime.types; + + default_type application/octet-stream; + + set_real_ip_from 0.0.0.0/0; + real_ip_recursive on; + real_ip_header X-Forwarded-For; + limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s; + + access_log /dev/stdout; + error_log /dev/stderr; + + server { + listen 3000; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /spaces/index.html; + } + } +} + diff --git a/apps/space/package.json b/apps/space/package.json new file mode 100644 index 0000000..ea56f37 --- /dev/null +++ b/apps/space/package.json @@ -0,0 +1,71 @@ +{ + "name": "space", + "version": "1.4.2", + "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": "catalog:", + "@fontsource/ibm-plex-mono": "catalog:", + "@fontsource/material-symbols-rounded": "catalog:", + "@headlessui/react": "catalog:", + "@makeplane/propel": "catalog:", + "@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": "catalog:", + "@react-router/node": "catalog:", + "@react-router/serve": "catalog:", + "axios": "catalog:", + "clsx": "catalog:", + "date-fns": "catalog:", + "isbot": "catalog:", + "lodash-es": "catalog:", + "lucide-react": "catalog:", + "mobx": "catalog:", + "mobx-react": "catalog:", + "mobx-utils": "catalog:", + "next-themes": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-dropzone": "catalog:", + "react-hook-form": "catalog:", + "react-popper": "catalog:", + "react-router": "catalog:", + "swr": "catalog:", + "uuid": "catalog:" + }, + "devDependencies": { + "@plane/tailwind-config": "workspace:*", + "@plane/typescript-config": "workspace:*", + "@react-router/dev": "catalog:", + "@tailwindcss/postcss": "catalog:", + "@tailwindcss/typography": "catalog:", + "@types/lodash-es": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "dotenv": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-tsconfig-paths": "catalog:" + } +} diff --git a/apps/space/postcss.config.js b/apps/space/postcss.config.js new file mode 100644 index 0000000..3ad28f1 --- /dev/null +++ b/apps/space/postcss.config.js @@ -0,0 +1,3 @@ +import postcssConfig from "@plane/tailwind-config/postcss.config.js"; + +export default postcssConfig; diff --git a/apps/space/public/favicon/android-chrome-192x192.png b/apps/space/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000..4a005e5 Binary files /dev/null and b/apps/space/public/favicon/android-chrome-192x192.png differ diff --git a/apps/space/public/favicon/android-chrome-512x512.png b/apps/space/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000..27fafe8 Binary files /dev/null and b/apps/space/public/favicon/android-chrome-512x512.png differ diff --git a/apps/space/public/site.webmanifest.json b/apps/space/public/site.webmanifest.json new file mode 100644 index 0000000..8885d13 --- /dev/null +++ b/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/apps/space/react-router.config.ts b/apps/space/react-router.config.ts new file mode 100644 index 0000000..78a1743 --- /dev/null +++ b/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/apps/space/store/cycle.store.ts b/apps/space/store/cycle.store.ts new file mode 100644 index 0000000..13cb695 --- /dev/null +++ b/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/apps/space/store/helpers/base-issues.store.ts b/apps/space/store/helpers/base-issues.store.ts new file mode 100644 index 0000000..e0fdb56 --- /dev/null +++ b/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/apps/space/store/helpers/filter.helpers.ts b/apps/space/store/helpers/filter.helpers.ts new file mode 100644 index 0000000..8a0e454 --- /dev/null +++ b/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/apps/space/store/instance.store.ts b/apps/space/store/instance.store.ts new file mode 100644 index 0000000..37dc931 --- /dev/null +++ b/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/apps/space/store/issue-detail.store.ts b/apps/space/store/issue-detail.store.ts new file mode 100644 index 0000000..6cbb6c8 --- /dev/null +++ b/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/apps/space/store/issue-filters.store.ts b/apps/space/store/issue-filters.store.ts new file mode 100644 index 0000000..e2cb514 --- /dev/null +++ b/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/apps/space/store/issue.store.ts b/apps/space/store/issue.store.ts new file mode 100644 index 0000000..93556bc --- /dev/null +++ b/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/apps/space/store/label.store.ts b/apps/space/store/label.store.ts new file mode 100644 index 0000000..b1c229f --- /dev/null +++ b/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/apps/space/store/members.store.ts b/apps/space/store/members.store.ts new file mode 100644 index 0000000..a06d005 --- /dev/null +++ b/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/apps/space/store/module.store.ts b/apps/space/store/module.store.ts new file mode 100644 index 0000000..c539846 --- /dev/null +++ b/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/apps/space/store/profile.store.ts b/apps/space/store/profile.store.ts new file mode 100644 index 0000000..d62d1d3 --- /dev/null +++ b/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/apps/space/store/publish/publish.store.ts b/apps/space/store/publish/publish.store.ts new file mode 100644 index 0000000..973ea86 --- /dev/null +++ b/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/apps/space/store/publish/publish_list.store.ts b/apps/space/store/publish/publish_list.store.ts new file mode 100644 index 0000000..eeebe97 --- /dev/null +++ b/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/apps/space/store/root.store.ts b/apps/space/store/root.store.ts new file mode 100644 index 0000000..6b52618 --- /dev/null +++ b/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/apps/space/store/state.store.ts b/apps/space/store/state.store.ts new file mode 100644 index 0000000..3ecf78d --- /dev/null +++ b/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/apps/space/store/user.store.ts b/apps/space/store/user.store.ts new file mode 100644 index 0000000..f2a3c7d --- /dev/null +++ b/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/apps/space/styles/globals.css b/apps/space/styles/globals.css new file mode 100644 index 0000000..4cddab5 --- /dev/null +++ b/apps/space/styles/globals.css @@ -0,0 +1,48 @@ +@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 + * Text colors and the themed light/dark background ramps come from + * @plane/editor/styles (imported above), which is their single source of truth. + * Only the un-themed :root fallback below is kept: the editor package declares + * background colors solely under [data-theme*="light"] / [data-theme*="dark"], + * so without this they are undefined until a theme lands on the element. */ +:root { + --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; +} + +/* 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/apps/space/tsconfig.json b/apps/space/tsconfig.json new file mode 100644 index 0000000..a340381 --- /dev/null +++ b/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/apps/space/types/auth.ts b/apps/space/types/auth.ts new file mode 100644 index 0000000..72e12cd --- /dev/null +++ b/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/apps/space/types/cycle.d.ts b/apps/space/types/cycle.d.ts new file mode 100644 index 0000000..edf8f31 --- /dev/null +++ b/apps/space/types/cycle.d.ts @@ -0,0 +1,5 @@ +export type TPublicCycle = { + id: string; + name: string; + status: string; +}; diff --git a/apps/space/types/intake.d.ts b/apps/space/types/intake.d.ts new file mode 100644 index 0000000..9cf2934 --- /dev/null +++ b/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/apps/space/types/issue.d.ts b/apps/space/types/issue.d.ts new file mode 100644 index 0000000..299aada --- /dev/null +++ b/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/apps/space/types/member.d.ts b/apps/space/types/member.d.ts new file mode 100644 index 0000000..34c95da --- /dev/null +++ b/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/apps/space/types/modules.d.ts b/apps/space/types/modules.d.ts new file mode 100644 index 0000000..8bc35ce --- /dev/null +++ b/apps/space/types/modules.d.ts @@ -0,0 +1,4 @@ +export type TPublicModule = { + id: string; + name: string; +}; diff --git a/apps/space/vite.config.ts b/apps/space/vite.config.ts new file mode 100644 index 0000000..1767562 --- /dev/null +++ b/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/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..90b7c43 --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +.react-router +.vite +.turbo +build +dist +*.log +.env* +!.env.example + diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..59663aa --- /dev/null +++ b/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/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..7d7c7a5 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,3 @@ + +# Sentry Config File +.env.sentry-build-plugin diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore new file mode 100644 index 0000000..b0b8bc6 --- /dev/null +++ b/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/apps/web/Dockerfile.dev b/apps/web/Dockerfile.dev new file mode 100644 index 0000000..d914fd8 --- /dev/null +++ b/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/apps/web/Dockerfile.web b/apps/web/Dockerfile.web new file mode 100644 index 0000000..ee9a54f --- /dev/null +++ b/apps/web/Dockerfile.web @@ -0,0 +1,126 @@ +# 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:$PNPM_HOME/bin:$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.18 +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 Caddy +# ***************************************************************************** +FROM caddy:2.11.4-builder-alpine AS caddy-builder + +RUN XCADDY_SKIP_CLEANUP=1 xcaddy build \ + --with github.com/mholt/caddy-ratelimit + +# Caddy's own go.mod pins x/crypto, x/net and x/text at versions with open CVEs +# (CVE-2026-56854 CRITICAL in x/crypto, CVE-2026-46600 in x/net, +# CVE-2026-56852 in x/text), so every xcaddy build re-ships them. xcaddy +# preserved its build dir above, so upgrade them there and rebuild. +# x/crypto v0.55.0 requires x/net v0.57.0 and x/text v0.41.0, hence those floors. +# grpc and otel move together: grpc >= v1.83.0 requires otel v1.44.0, so bump +# the otel pair in lockstep or go walks grpc backwards to satisfy the older one. +RUN cd "$(find /tmp -maxdepth 1 -name 'buildenv_*' -type d | head -1)" && \ + go get golang.org/x/crypto@v0.55.0 && \ + go get golang.org/x/net@v0.57.0 && \ + go get golang.org/x/text@v0.41.0 && \ + go get google.golang.org/grpc@v1.83.1 && \ + go get go.opentelemetry.io/otel@v1.44.0 && \ + go get go.opentelemetry.io/otel/sdk@v1.44.0 && \ + go mod tidy && \ + go build -o /usr/bin/caddy -ldflags "-w -s" -trimpath . + +# `go get` resolves a module backwards rather than failing when pins conflict, +# so assert the binary really carries the pinned grpc: a silent downgrade must +# fail the build instead of shipping a caddy that only looks patched. +RUN go version -m /usr/bin/caddy | grep -qE 'google\.golang\.org/grpc[[:space:]]v1\.83\.1[[:space:]]' \ + || { echo "caddy was not built against the pinned grpc -- check the go get floors" >&2; exit 1; } + +FROM caddy:2.11.4-alpine AS production + +# curl is required by the HEALTHCHECK below and is not present in the base image. +# OS security updates too: --available forces reinstall from the current repos; +# bump APK_SECURITY_PATCH to bust buildx's cached layer (keyed on this command +# string), otherwise a rebuild silently re-ships the old packages. +ARG APK_SECURITY_PATCH=2026-09-07 +RUN echo "apk-security-patch ${APK_SECURITY_PATCH}" && apk update && apk upgrade --no-cache --available && apk add --no-cache curl && rm -rf /var/cache/apk/* + +COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy + +COPY apps/web/caddy/Caddyfile /etc/caddy/Caddyfile + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1 + +COPY --from=installer /app/apps/web/build/client /usr/share/caddy/html + +EXPOSE 3000 + +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] \ No newline at end of file diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx new file mode 100644 index 0000000..173b1fb --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx new file mode 100644 index 0000000..48a37bc --- /dev/null +++ b/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 { CyclesOutline } from "@makeplane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +// plane web components +import { UpgradeBadge } from "@/components/workspace/upgrade-badge"; + +export const WorkspaceActiveCycleHeader = observer(function WorkspaceActiveCycleHeader() { + const { t } = useTranslation(); + return ( +
+ + + } + /> + } + /> + + + +
+ ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx new file mode 100644 index 0000000..fb00022 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx new file mode 100644 index 0000000..12e3afe --- /dev/null +++ b/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"; +// local imports +import { WorkspaceActiveCyclesUpgrade } from "@/components/active-cycles/workspace-active-cycles-upgrade"; + +function WorkspaceActiveCyclesPage() { + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Active Cycles` : undefined; + + return ( + <> + + + + ); +} + +export default observer(WorkspaceActiveCyclesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx new file mode 100644 index 0000000..992476e --- /dev/null +++ b/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 { AnalyticsOutline } from "@makeplane/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/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx new file mode 100644 index 0000000..e123660 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx new file mode 100644 index 0000000..e1e985e --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.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 { 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 { Tab, Tabs, TabsList, TabsPanel } from "@makeplane/propel/components/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 "@/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) => ( + + ))} + + +
+ +
+
+ {/* Grid wrapper: Propel's TabsPanel omits className, so the single mounted panel + gets its fill height from a one-row grid instead. */} +
+ {ANALYTICS_TABS.map((tab) => ( + + + + ))} +
+
+
+
+ ) : ( + { + toggleCreateProjectModal(true); + }, + disabled: !canPerformEmptyStateActions, + }, + ]} + /> + )} + + )} + + ); +} + +export default observer(AnalyticsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx new file mode 100644 index 0000000..88df260 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx new file mode 100644 index 0000000..b8b8a58 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx new file mode 100644 index 0000000..a644c40 --- /dev/null +++ b/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 "@/hooks/use-issue-properties"; +import { WorkItemDetailRoot } from "@/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/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/work-item-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/work-item-header.tsx new file mode 100644 index 0000000..4b7111e --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/work-item-header.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 React from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane ui +import { WorkItemsOutline } from "@makeplane/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 "@/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(); + // 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/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx new file mode 100644 index 0000000..027b8ed --- /dev/null +++ b/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 { DraftsOutline } from "@makeplane/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/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx new file mode 100644 index 0000000..d7df53f --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx new file mode 100644 index 0000000..300fbce --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx new file mode 100644 index 0000000..5d7c69f --- /dev/null +++ b/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 } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import { AddOutline, SearchOutline } from "@makeplane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Tooltip } from "@makeplane/propel/components/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/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/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx new file mode 100644 index 0000000..90030a3 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx new file mode 100644 index 0000000..c8f5eb8 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.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 { 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"; +// components +import { ExtendedSidebarItem } from "@/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; + }) + // oxlint-disable-next-line oxc/no-map-spread + .map((item) => { + const preference = currentWorkspaceNavigationPreferences?.[item.key]; + return { + ...item, + sort_order: preference?.sort_order ?? 0, + is_pinned: preference?.is_pinned ?? false, + }; + }) + // oxlint-disable-next-line unicorn/no-array-sort + .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); + + // oxlint-disable-next-line unicorn/consistent-function-scoping + 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/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx new file mode 100644 index 0000000..ce78452 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx @@ -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 { observer } from "mobx-react"; +import { HomeOutline, WidgetOutline } from "@makeplane/propel/icons"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +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/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx new file mode 100644 index 0000000..090eefb --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx new file mode 100644 index 0000000..5188dfd --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx new file mode 100644 index 0000000..ad99b55 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx new file mode 100644 index 0000000..4d4547a --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx new file mode 100644 index 0000000..1f1fe4c --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx new file mode 100644 index 0000000..7bd9364 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx new file mode 100644 index 0000000..28f415b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx @@ -0,0 +1,113 @@ +/** + * 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 { ChevronDownOutline, RightSidePaneOutline, YourWorkOutline } from "@makeplane/propel/icons"; +import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +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/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx new file mode 100644 index 0000000..f5e2d66 --- /dev/null +++ b/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 "@plane/constants"; +// 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/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx new file mode 100644 index 0000000..fa8b73d --- /dev/null +++ b/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 { ChevronDownOutline } from "@makeplane/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/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx new file mode 100644 index 0000000..4d99983 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx new file mode 100644 index 0000000..6118a86 --- /dev/null +++ b/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 "@plane/constants"; +// 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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx new file mode 100644 index 0000000..1b3fc1e --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx new file mode 100644 index 0000000..d25a11b --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx new file mode 100644 index 0000000..fcfdfe3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -0,0 +1,112 @@ +/** + * 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 { ArchiveOutline, CyclesOutline, ModuleOutline, WorkItemsOutline } from "@makeplane/propel/icons"; +import { Tooltip } from "@makeplane/propel/components/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 "@/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: WorkItemsOutline, + }, + cycles: { + label: "Cycles", + href: "/cycles", + icon: CyclesOutline, + }, + modules: { + label: "Modules", + href: "/modules", + icon: ModuleOutline, + }, +}; + +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`} + layout="stacked" + side="bottom" + disabled={isMobile} + > + + {issueCount} + + + ) : null} +
+
+
+ ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx new file mode 100644 index 0000000..135167d --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx @@ -0,0 +1,102 @@ +/** + * 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 "@makeplane/propel/components/banner"; +import { ArchiveOutline } from "@makeplane/propel/icons"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +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 { t } = useTranslation(); + 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 ? ( + +
+ + + + +
+
+ + + + +
+
+ ) : ( + <> + } + actions={ + + } + /> +
+
+ +
+
+ + )} + + ); +} + +export default observer(ArchivedIssueDetailsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx new file mode 100644 index 0000000..dca1111 --- /dev/null +++ b/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 { ArchiveOutline, WorkItemsOutline } from "@makeplane/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 "@plane/constants"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +// plane web +import { ProjectBreadcrumb } from "@/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx new file mode 100644 index 0000000..1d99a73 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx new file mode 100644 index 0000000..6385a3a --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx new file mode 100644 index 0000000..0a493cb --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx new file mode 100644 index 0000000..0101dcf --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx new file mode 100644 index 0000000..39a6a16 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx new file mode 100644 index 0000000..4142152 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx new file mode 100644 index 0000000..cb5425b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -0,0 +1,273 @@ +/** + * 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 { BarOutline, CyclesOutline, PreferencesOutline, RightSidePaneOutline } from "@makeplane/propel/icons"; +// plane imports +import { + EIssueFilterType, + EUserPermissions, + EUserPermissionsLevel, + ISSUE_DISPLAY_FILTERS_BY_PAGE, +} 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 { Tooltip } from "@makeplane/propel/components/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 "@/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 === true; + 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`} + layout="stacked" + side="bottom" + disabled={isMobile} + > + + {workItemsCount} + + + ) : null} +
+
+ +
+
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> +
+
+ handleLayoutChange(layout)} + activeLayout={activeLayout} + /> +
+ + } + > + + + + {canUserCreateIssue && ( + <> + + {!isCompletedCycle && ( + + )} + + )} + + +
+
+
+ + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx new file mode 100644 index 0000000..2d92265 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx new file mode 100644 index 0000000..4c00514 --- /dev/null +++ b/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 { BoardOutline, CalendarOutline, ChevronDownOutline, ListOutline } from "@makeplane/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: ListOutline }, + { key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: BoardOutline }, + { key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: CalendarOutline }, +]; + +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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx new file mode 100644 index 0000000..2f87151 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -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 { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// ui +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { CyclesOutline } from "@makeplane/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 "@/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx new file mode 100644 index 0000000..c1b0033 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx new file mode 100644 index 0000000..6784458 --- /dev/null +++ b/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 { GridOutline, ListOutline, TimelineOutline } from "@makeplane/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: ListOutline, + title: "List layout", + }, + { + key: "board", + icon: GridOutline, + title: "Gallery layout", + }, + { + key: "gantt", + icon: TimelineOutline, + 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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx new file mode 100644 index 0000000..c430e16 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/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 } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { useTheme } from "next-themes"; +import { EUserPermissionsLevel } 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, + }, + ]} + /> +
+ ) : ( + <> + {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( +
+ clearAllFilters(projectId)} + handleRemoveFilter={handleRemoveFilter} + /> +
+ )} + + + + )} +
+ + ); +} + +export default observer(ProjectCyclesPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx new file mode 100644 index 0000000..99c88f9 --- /dev/null +++ b/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 "@/components/projects/settings/intake/header"; + +export default function ProjectInboxIssuesLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx new file mode 100644 index 0000000..64739d6 --- /dev/null +++ b/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: "Plane", + }); + + const currentNavigationTab = navigationTab + ? navigationTab === "open" + ? EInboxIssueCurrentTab.OPEN + : EInboxIssueCurrentTab.CLOSED + : undefined; + + return ( +
+ +
+ +
+
+ ); +} + +export default observer(ProjectInboxPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx new file mode 100644 index 0000000..1fab5aa --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx new file mode 100644 index 0000000..f350dbf --- /dev/null +++ b/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 "@/components/issues/header"; + +export function ProjectIssuesHeader() { + return ; +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx new file mode 100644 index 0000000..c8ac595 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx new file mode 100644 index 0000000..75916ac --- /dev/null +++ b/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 { ChevronDownOutline } from "@makeplane/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx new file mode 100644 index 0000000..15ebb8b --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx new file mode 100644 index 0000000..e43b6eb --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx new file mode 100644 index 0000000..3a95896 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx new file mode 100644 index 0000000..2a0e462 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -0,0 +1,270 @@ +/** + * 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 { BarOutline, ModuleOutline, PreferencesOutline, RightSidePaneOutline } from "@makeplane/propel/icons"; +// plane imports +import { + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + EUserPermissions, + EUserPermissionsLevel, +} from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { Tooltip } from "@makeplane/propel/components/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 "@/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" : 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`} + layout="stacked" + side="bottom" + disabled={isMobile} + > + + {workItemsCount} + + + ) : null} +
+
+ +
+
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> +
+
+ handleLayoutChange(layout)} + activeLayout={activeLayout} + /> +
+ {moduleId && } + } + > + + +
+ + {canUserCreateIssue ? ( + <> + + + + ) : ( + <> + )} + + {moduleId && ( + + )} +
+
+ + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx new file mode 100644 index 0000000..f11b906 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx new file mode 100644 index 0000000..b6a5c67 --- /dev/null +++ b/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 { BoardOutline, CalendarOutline, ChevronDownOutline, ListOutline } from "@makeplane/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: ListOutline }, + { key: "kanban", i18n_title: "issue.layouts.kanban", icon: BoardOutline }, + { key: "calendar", i18n_title: "issue.layouts.calendar", icon: CalendarOutline }, +]; + +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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx new file mode 100644 index 0000000..41d1666 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.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"; +import { useParams } from "next/navigation"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button } from "@plane/propel/button"; +import { ModuleOutline } from "@makeplane/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 "@/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx new file mode 100644 index 0000000..46eb9f1 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx new file mode 100644 index 0000000..4145792 --- /dev/null +++ b/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 { ChevronDownOutline } from "@makeplane/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx new file mode 100644 index 0000000..12e0be1 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx new file mode 100644 index 0000000..fc1f76a --- /dev/null +++ b/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 "@/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx new file mode 100644 index 0000000..d005cfb --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.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 { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { PagesOutline } from "@makeplane/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"; +import { CommonProjectBreadcrumbs } from "@/components/breadcrumbs/common"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { EPageStoreType, usePage, usePageStore } from "@/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx new file mode 100644 index 0000000..b8f4080 --- /dev/null +++ b/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 "@/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx new file mode 100644 index 0000000..7105d1c --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.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 } 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 { PagesOutline } from "@makeplane/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 "@/components/breadcrumbs/common"; +import { EPageStoreType, usePageStore } from "@/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) + // oxlint-disable-next-line promise/always-return + .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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx new file mode 100644 index 0000000..b803854 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx new file mode 100644 index 0000000..540e87c --- /dev/null +++ b/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 "@/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx new file mode 100644 index 0000000..5db2b2f --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -0,0 +1,221 @@ +/** + * 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, +} from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { LockOutline, ViewsOutline } from "@makeplane/propel/icons"; +import { Tooltip } from "@makeplane/propel/components/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 "@/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx new file mode 100644 index 0000000..c4c03eb --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx new file mode 100644 index 0000000..b33381b --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx new file mode 100644 index 0000000..a4304aa --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/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. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// ui +import { Button } from "@plane/propel/button"; +import { ViewsOutline } from "@makeplane/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 "@/components/breadcrumbs/common"; + +export const ProjectViewsHeader = observer(function ProjectViewsHeader() { + const { workspaceSlug, projectId } = useParams(); + // store hooks + const { toggleCreateViewModal } = useCommandPalette(); + const { loader } = useProject(); + + return ( + <> +
+ + + + } + isLast + /> + } + isLast + /> + + + + +
+ +
+
+
+ + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx new file mode 100644 index 0000000..0b6dda9 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx new file mode 100644 index 0000000..bbe4d13 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx @@ -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 { observer } from "mobx-react"; +// icons +import { ChevronDownOutline, FilterOutline } from "@makeplane/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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx new file mode 100644 index 0000000..b8f7f36 --- /dev/null +++ b/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} - 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/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx new file mode 100644 index 0000000..219d9db --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/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 { ProjectsListMobileHeader } from "@/components/projects/mobile-header"; +import { ProjectsBaseHeader } from "@/components/project/header"; + +export default function ProjectListLayout() { + return ( + <> + } mobileHeader={} /> + + + + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx new file mode 100644 index 0000000..70a471f --- /dev/null +++ b/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 "@/components/projects/page"; + +function ProjectsPage() { + return ; +} + +export default ProjectsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx new file mode 100644 index 0000000..0f39520 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(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 { ProjectsListMobileHeader } from "@/components/projects/mobile-header"; +import { ProjectsBaseHeader } from "@/components/project/header"; + +export default function ProjectListLayout() { + return ( + <> + } mobileHeader={} /> + + + + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx new file mode 100644 index 0000000..70a471f --- /dev/null +++ b/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 "@/components/projects/page"; + +function ProjectsPage() { + return ; +} + +export default ProjectsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx new file mode 100644 index 0000000..c94b109 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.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 { isEmpty } from "lodash-es"; +import { observer } from "mobx-react"; +// plane helpers +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +// 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"; + +export const AppSidebar = observer(function AppSidebar() { + // 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 && } + {/* Projects List */} + + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx new file mode 100644 index 0000000..c4039f7 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx new file mode 100644 index 0000000..aef45ee --- /dev/null +++ b/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 { MultipleStickyOutline } from "@makeplane/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/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx new file mode 100644 index 0000000..14be62a --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx new file mode 100644 index 0000000..d925b2c --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx new file mode 100644 index 0000000..3d62ade --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx new file mode 100644 index 0000000..c852b4d --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.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. + */ + +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, DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { ViewsOutline } from "@makeplane/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"; + +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 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 + /> + + + + + {globalViewId && } + {!isLocked && ( + + + + )} + +
+ {viewDetails && } + {isDefaultView && defaultViewDetails && ( + + )} +
+
+
+ + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx new file mode 100644 index 0000000..60f6e5a --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx new file mode 100644 index 0000000..eeaee13 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/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 React, { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { Input, InputGroup } from "@makeplane/propel/components/input"; +import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { SearchOutline } from "@makeplane/propel/icons"; + +// 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" + aria-label="Search" + /> + +
+
+ {DEFAULT_GLOBAL_VIEWS_LIST.filter((v) => t(v.i18n_label).toLowerCase().includes(query.toLowerCase())).map( + (option) => ( + + ) + )} + +
+
+ + ); +} + +export default observer(WorkspaceViewsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx new file mode 100644 index 0000000..a8d2c4b --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/header.tsx new file mode 100644 index 0000000..6172567 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx new file mode 100644 index 0000000..3f9383f --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/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 { observer } from "mobx-react"; +// component +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"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import { BillingWorkspaceSettingsHeader } from "./header"; +import { BillingRoot } from "@/components/workspace/billing"; + +function BillingSettingsPage() { + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentWorkspace } = useWorkspace(); + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined; + + if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { + return ; + } + + return ( + } hugging> + + + + ); +} + +export default observer(BillingSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/header.tsx new file mode 100644 index 0000000..2cc08a4 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx new file mode 100644 index 0000000..af6141b --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/header.tsx new file mode 100644 index 0000000..7a69bb2 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx new file mode 100644 index 0000000..0344f8a --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx @@ -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 { observer } from "mobx-react"; +import useSWR from "swr"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +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 { IntegrationsSettingsLoader } from "@/components/ui/loader/settings/integration"; +// constants +import { APP_INTEGRATIONS } from "@plane/constants"; +// 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() { + // translation + const { t } = useTranslation(); + // 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 ( + <> + +
+
+

{t("integrations.integrations")}

+
+
+ {appIntegrations ? ( + appIntegrations.map((integration) => ( + + )) + ) : ( + + )} +
+
+ + ); +} + +export default observer(WorkspaceIntegrationsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx new file mode 100644 index 0000000..a57df1e --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/header.tsx new file mode 100644 index 0000000..243f8da --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx new file mode 100644 index 0000000..142f7b3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx @@ -0,0 +1,151 @@ +/** + * 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 { SearchOutline } from "@makeplane/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"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SendWorkspaceInvitationModal } from "@/components/workspace/members"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +// 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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx new file mode 100644 index 0000000..55422ec --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/header.tsx new file mode 100644 index 0000000..7c24c6b --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx new file mode 100644 index 0000000..5cb9d3c --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/header.tsx new file mode 100644 index 0000000..0916bf9 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx new file mode 100644 index 0000000..099a0ee --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/header.tsx new file mode 100644 index 0000000..df9ae6e --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx new file mode 100644 index 0000000..d2be455 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/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 { observer } from "mobx-react"; +import { Outlet } from "react-router"; + +function AutomationsListLayout() { + return ; +} + +export default observer(AutomationsListLayout); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx new file mode 100644 index 0000000..3a8b986 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.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"; +// 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"; +// 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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/header.tsx new file mode 100644 index 0000000..447f925 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx new file mode 100644 index 0000000..cfa1e94 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/header.tsx new file mode 100644 index 0000000..c2fa9de --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx new file mode 100644 index 0000000..b86a1b2 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/header.tsx new file mode 100644 index 0000000..5f81d40 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx new file mode 100644 index 0000000..3349a79 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/header.tsx new file mode 100644 index 0000000..baf32b2 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx new file mode 100644 index 0000000..1253f0d --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/header.tsx new file mode 100644 index 0000000..fa7a40e --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx new file mode 100644 index 0000000..2b018e5 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/header.tsx new file mode 100644 index 0000000..c8429d9 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx new file mode 100644 index 0000000..594028f --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/header.tsx new file mode 100644 index 0000000..95d4720 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/header.tsx new file mode 100644 index 0000000..b9daaae --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx new file mode 100644 index 0000000..870c05f --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx new file mode 100644 index 0000000..6ebe9df --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/header.tsx new file mode 100644 index 0000000..e8e3c4f --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx new file mode 100644 index 0000000..7df00fc --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/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 { 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"; +// 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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx new file mode 100644 index 0000000..39a8046 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/header.tsx new file mode 100644 index 0000000..957632b --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx new file mode 100644 index 0000000..9913d52 --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx new file mode 100644 index 0000000..86229fe --- /dev/null +++ b/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/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx new file mode 100644 index 0000000..1d38154 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.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"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +// plane imports +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/apps/web/app/(all)/[workspaceSlug]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/layout.tsx new file mode 100644 index 0000000..1c295ee --- /dev/null +++ b/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 "@/components/workspace/content-wrapper"; +import { AppRailVisibilityProvider } from "@/lib/app-rail"; +import { GlobalModals } from "@/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/apps/web/app/(all)/accounts/forgot-password/layout.tsx b/apps/web/app/(all)/accounts/forgot-password/layout.tsx new file mode 100644 index 0000000..f503c81 --- /dev/null +++ b/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 - Plane" }]; diff --git a/apps/web/app/(all)/accounts/forgot-password/page.tsx b/apps/web/app/(all)/accounts/forgot-password/page.tsx new file mode 100644 index 0000000..eb71311 --- /dev/null +++ b/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/apps/web/app/(all)/accounts/reset-password/layout.tsx b/apps/web/app/(all)/accounts/reset-password/layout.tsx new file mode 100644 index 0000000..d3955da --- /dev/null +++ b/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 - Plane" }]; diff --git a/apps/web/app/(all)/accounts/reset-password/page.tsx b/apps/web/app/(all)/accounts/reset-password/page.tsx new file mode 100644 index 0000000..c9914f0 --- /dev/null +++ b/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/apps/web/app/(all)/accounts/set-password/layout.tsx b/apps/web/app/(all)/accounts/set-password/layout.tsx new file mode 100644 index 0000000..4455b84 --- /dev/null +++ b/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 - Plane" }]; diff --git a/apps/web/app/(all)/accounts/set-password/page.tsx b/apps/web/app/(all)/accounts/set-password/page.tsx new file mode 100644 index 0000000..493f86d --- /dev/null +++ b/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/apps/web/app/(all)/create-workspace/layout.tsx b/apps/web/app/(all)/create-workspace/layout.tsx new file mode 100644 index 0000000..95b73bf --- /dev/null +++ b/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/apps/web/app/(all)/create-workspace/page.tsx b/apps/web/app/(all)/create-workspace/page.tsx new file mode 100644 index 0000000..3808718 --- /dev/null +++ b/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/apps/web/app/(all)/invitations/layout.tsx b/apps/web/app/(all)/invitations/layout.tsx new file mode 100644 index 0000000..68f4fe7 --- /dev/null +++ b/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/apps/web/app/(all)/invitations/page.tsx b/apps/web/app/(all)/invitations/page.tsx new file mode 100644 index 0000000..f325f27 --- /dev/null +++ b/apps/web/app/(all)/invitations/page.tsx @@ -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 { useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; + +import useSWR, { mutate } from "swr"; +import { TickCircleOutline } from "@makeplane/propel/icons"; +// plane imports +import { ROLE } 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 "@plane/constants"; +// 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)}
+

{ROLE[invitation.role]}

+
+ + + +
+ ); + })} +
+
+ + + + + + +
+
+
+ ) : ( +
+ router.push("/"), + }} + /> +
+ ) + ) : null} +
+ + ); +} + +export default observer(UserInvitationsPage); diff --git a/apps/web/app/(all)/layout.preload.tsx b/apps/web/app/(all)/layout.preload.tsx new file mode 100644 index 0000000..e070eaa --- /dev/null +++ b/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/apps/web/app/(all)/layout.tsx b/apps/web/app/(all)/layout.tsx new file mode 100644 index 0000000..ab06034 --- /dev/null +++ b/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/apps/web/app/(all)/onboarding/layout.tsx b/apps/web/app/(all)/onboarding/layout.tsx new file mode 100644 index 0000000..fee10f7 --- /dev/null +++ b/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/apps/web/app/(all)/onboarding/page.tsx b/apps/web/app/(all)/onboarding/page.tsx new file mode 100644 index 0000000..86ef1b1 --- /dev/null +++ b/apps/web/app/(all)/onboarding/page.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 useSWR from "swr"; + +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { OnboardingRoot } from "@/components/onboarding"; +// constants +import { USER_WORKSPACES_LIST } from "@plane/constants"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUser } from "@/hooks/store/user"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; +// services +import { WorkspaceService } from "@/services/workspace.service"; + +const workspaceService = new WorkspaceService(); + +function OnboardingPage() { + // store hooks + const { data: user } = useUser(); + const { fetchWorkspaces } = useWorkspace(); + + // fetching workspaces list + useSWR(USER_WORKSPACES_LIST, () => { + if (user?.id) { + fetchWorkspaces(); + } + }); + + // fetching user workspace invitations + const { isLoading: invitationsLoader, data: invitations } = useSWR( + `USER_WORKSPACE_INVITATIONS_LIST_${user?.id}`, + () => { + if (user?.id) return workspaceService.userWorkspaceInvitations(); + } + ); + + return ( + +
+
+
+ {user && !invitationsLoader ? ( + + ) : ( +
+ +
+ )} +
+
+
+
+ ); +} + +export default observer(OnboardingPage); diff --git a/apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx b/apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx new file mode 100644 index 0000000..d7df27c --- /dev/null +++ b/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/apps/web/app/(all)/settings/profile/layout.tsx b/apps/web/app/(all)/settings/profile/layout.tsx new file mode 100644 index 0000000..a30518f --- /dev/null +++ b/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/apps/web/app/(all)/sign-up/layout.tsx b/apps/web/app/(all)/sign-up/layout.tsx new file mode 100644 index 0000000..97a8147 --- /dev/null +++ b/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 - Plane" }, + { name: "robots", content: "index, nofollow" }, +]; + +export default function SignUpLayout() { + return ; +} diff --git a/apps/web/app/(all)/sign-up/page.tsx b/apps/web/app/(all)/sign-up/page.tsx new file mode 100644 index 0000000..639d765 --- /dev/null +++ b/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/apps/web/app/(all)/workspace-invitations/layout.tsx b/apps/web/app/(all)/workspace-invitations/layout.tsx new file mode 100644 index 0000000..b9a7337 --- /dev/null +++ b/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/apps/web/app/(all)/workspace-invitations/page.tsx b/apps/web/app/(all)/workspace-invitations/page.tsx new file mode 100644 index 0000000..d7e580e --- /dev/null +++ b/apps/web/app/(all)/workspace-invitations/page.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 { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import useSWR from "swr"; +import { + BoxesOutline, + CloseOutline, + ShareAltOutline, + StarOutline, + TickOutline, + UserOutline, +} from "@makeplane/propel/icons"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { EmptySpace, EmptySpaceItem } from "@/components/ui/empty-space"; +// constants +import { WORKSPACE_INVITATION } from "@plane/constants"; +// 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/apps/web/app/(home)/layout.tsx b/apps/web/app/(home)/layout.tsx new file mode 100644 index 0000000..c723b07 --- /dev/null +++ b/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/apps/web/app/(home)/page.tsx b/apps/web/app/(home)/page.tsx new file mode 100644 index 0000000..4717705 --- /dev/null +++ b/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/apps/web/app/assets/404.svg b/apps/web/app/assets/404.svg new file mode 100644 index 0000000..4c29841 --- /dev/null +++ b/apps/web/app/assets/404.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/attachment/audio-icon.png b/apps/web/app/assets/attachment/audio-icon.png new file mode 100644 index 0000000..a3e551e Binary files /dev/null and b/apps/web/app/assets/attachment/audio-icon.png differ diff --git a/apps/web/app/assets/attachment/css-icon.png b/apps/web/app/assets/attachment/css-icon.png new file mode 100644 index 0000000..cfb502d Binary files /dev/null and b/apps/web/app/assets/attachment/css-icon.png differ diff --git a/apps/web/app/assets/attachment/csv-icon.png b/apps/web/app/assets/attachment/csv-icon.png new file mode 100644 index 0000000..39d0ee7 Binary files /dev/null and b/apps/web/app/assets/attachment/csv-icon.png differ diff --git a/apps/web/app/assets/attachment/default-icon.png b/apps/web/app/assets/attachment/default-icon.png new file mode 100644 index 0000000..eb1ea51 Binary files /dev/null and b/apps/web/app/assets/attachment/default-icon.png differ diff --git a/apps/web/app/assets/attachment/doc-icon.png b/apps/web/app/assets/attachment/doc-icon.png new file mode 100644 index 0000000..d143372 Binary files /dev/null and b/apps/web/app/assets/attachment/doc-icon.png differ diff --git a/apps/web/app/assets/attachment/excel-icon.png b/apps/web/app/assets/attachment/excel-icon.png new file mode 100644 index 0000000..b3a1b85 Binary files /dev/null and b/apps/web/app/assets/attachment/excel-icon.png differ diff --git a/apps/web/app/assets/attachment/figma-icon.png b/apps/web/app/assets/attachment/figma-icon.png new file mode 100644 index 0000000..b4a1b63 Binary files /dev/null and b/apps/web/app/assets/attachment/figma-icon.png differ diff --git a/apps/web/app/assets/attachment/html-icon.png b/apps/web/app/assets/attachment/html-icon.png new file mode 100644 index 0000000..b6259a2 Binary files /dev/null and b/apps/web/app/assets/attachment/html-icon.png differ diff --git a/apps/web/app/assets/attachment/img-icon.png b/apps/web/app/assets/attachment/img-icon.png new file mode 100644 index 0000000..6c5b8fc Binary files /dev/null and b/apps/web/app/assets/attachment/img-icon.png differ diff --git a/apps/web/app/assets/attachment/jpg-icon.png b/apps/web/app/assets/attachment/jpg-icon.png new file mode 100644 index 0000000..dfd2c9f Binary files /dev/null and b/apps/web/app/assets/attachment/jpg-icon.png differ diff --git a/apps/web/app/assets/attachment/js-icon.png b/apps/web/app/assets/attachment/js-icon.png new file mode 100644 index 0000000..66aacda Binary files /dev/null and b/apps/web/app/assets/attachment/js-icon.png differ diff --git a/apps/web/app/assets/attachment/pdf-icon.png b/apps/web/app/assets/attachment/pdf-icon.png new file mode 100644 index 0000000..21c42d7 Binary files /dev/null and b/apps/web/app/assets/attachment/pdf-icon.png differ diff --git a/apps/web/app/assets/attachment/png-icon.png b/apps/web/app/assets/attachment/png-icon.png new file mode 100644 index 0000000..f04207d Binary files /dev/null and b/apps/web/app/assets/attachment/png-icon.png differ diff --git a/apps/web/app/assets/attachment/rar-icon.png b/apps/web/app/assets/attachment/rar-icon.png new file mode 100644 index 0000000..7305455 Binary files /dev/null and b/apps/web/app/assets/attachment/rar-icon.png differ diff --git a/apps/web/app/assets/attachment/svg-icon.png b/apps/web/app/assets/attachment/svg-icon.png new file mode 100644 index 0000000..856f94f Binary files /dev/null and b/apps/web/app/assets/attachment/svg-icon.png differ diff --git a/apps/web/app/assets/attachment/txt-icon.png b/apps/web/app/assets/attachment/txt-icon.png new file mode 100644 index 0000000..1c1babf Binary files /dev/null and b/apps/web/app/assets/attachment/txt-icon.png differ diff --git a/apps/web/app/assets/attachment/video-icon.png b/apps/web/app/assets/attachment/video-icon.png new file mode 100644 index 0000000..510838d Binary files /dev/null and b/apps/web/app/assets/attachment/video-icon.png differ diff --git a/apps/web/app/assets/attachment/zip-icon.png b/apps/web/app/assets/attachment/zip-icon.png new file mode 100644 index 0000000..0db1da1 Binary files /dev/null and b/apps/web/app/assets/attachment/zip-icon.png differ diff --git a/apps/web/app/assets/auth/access-denied.svg b/apps/web/app/assets/auth/access-denied.svg new file mode 100644 index 0000000..c7979fe --- /dev/null +++ b/apps/web/app/assets/auth/access-denied.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/auth/background-pattern-dark.svg b/apps/web/app/assets/auth/background-pattern-dark.svg new file mode 100644 index 0000000..c258cba --- /dev/null +++ b/apps/web/app/assets/auth/background-pattern-dark.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/auth/background-pattern.svg b/apps/web/app/assets/auth/background-pattern.svg new file mode 100644 index 0000000..5fcbeec --- /dev/null +++ b/apps/web/app/assets/auth/background-pattern.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/auth/gradient-bg-logo.webp b/apps/web/app/assets/auth/gradient-bg-logo.webp new file mode 100644 index 0000000..47202e5 Binary files /dev/null and b/apps/web/app/assets/auth/gradient-bg-logo.webp differ diff --git a/apps/web/app/assets/auth/gradient-logo.webp b/apps/web/app/assets/auth/gradient-logo.webp new file mode 100644 index 0000000..674434c Binary files /dev/null and b/apps/web/app/assets/auth/gradient-logo.webp differ diff --git a/apps/web/app/assets/auth/project-not-authorized.svg b/apps/web/app/assets/auth/project-not-authorized.svg new file mode 100644 index 0000000..515fd68 --- /dev/null +++ b/apps/web/app/assets/auth/project-not-authorized.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/auth/unauthorized.svg b/apps/web/app/assets/auth/unauthorized.svg new file mode 100644 index 0000000..2bad48a --- /dev/null +++ b/apps/web/app/assets/auth/unauthorized.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Error unauthorized + \ No newline at end of file diff --git a/apps/web/app/assets/auth/workspace-not-authorized.svg b/apps/web/app/assets/auth/workspace-not-authorized.svg new file mode 100644 index 0000000..dad2b0c --- /dev/null +++ b/apps/web/app/assets/auth/workspace-not-authorized.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/cover-images/image_1.jpg b/apps/web/app/assets/cover-images/image_1.jpg new file mode 100644 index 0000000..3565e88 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_1.jpg differ diff --git a/apps/web/app/assets/cover-images/image_10.jpg b/apps/web/app/assets/cover-images/image_10.jpg new file mode 100644 index 0000000..cecad95 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_10.jpg differ diff --git a/apps/web/app/assets/cover-images/image_11.jpg b/apps/web/app/assets/cover-images/image_11.jpg new file mode 100644 index 0000000..ebe34f2 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_11.jpg differ diff --git a/apps/web/app/assets/cover-images/image_12.jpg b/apps/web/app/assets/cover-images/image_12.jpg new file mode 100644 index 0000000..cae254d Binary files /dev/null and b/apps/web/app/assets/cover-images/image_12.jpg differ diff --git a/apps/web/app/assets/cover-images/image_13.jpg b/apps/web/app/assets/cover-images/image_13.jpg new file mode 100644 index 0000000..a3f4d40 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_13.jpg differ diff --git a/apps/web/app/assets/cover-images/image_14.jpg b/apps/web/app/assets/cover-images/image_14.jpg new file mode 100644 index 0000000..3b3da88 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_14.jpg differ diff --git a/apps/web/app/assets/cover-images/image_15.jpg b/apps/web/app/assets/cover-images/image_15.jpg new file mode 100644 index 0000000..3e44b7f Binary files /dev/null and b/apps/web/app/assets/cover-images/image_15.jpg differ diff --git a/apps/web/app/assets/cover-images/image_16.jpg b/apps/web/app/assets/cover-images/image_16.jpg new file mode 100644 index 0000000..f31335b Binary files /dev/null and b/apps/web/app/assets/cover-images/image_16.jpg differ diff --git a/apps/web/app/assets/cover-images/image_17.jpg b/apps/web/app/assets/cover-images/image_17.jpg new file mode 100644 index 0000000..d2a5034 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_17.jpg differ diff --git a/apps/web/app/assets/cover-images/image_18.jpg b/apps/web/app/assets/cover-images/image_18.jpg new file mode 100644 index 0000000..10cf37c Binary files /dev/null and b/apps/web/app/assets/cover-images/image_18.jpg differ diff --git a/apps/web/app/assets/cover-images/image_19.jpg b/apps/web/app/assets/cover-images/image_19.jpg new file mode 100644 index 0000000..dca5619 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_19.jpg differ diff --git a/apps/web/app/assets/cover-images/image_2.jpg b/apps/web/app/assets/cover-images/image_2.jpg new file mode 100644 index 0000000..a1adad5 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_2.jpg differ diff --git a/apps/web/app/assets/cover-images/image_20.jpg b/apps/web/app/assets/cover-images/image_20.jpg new file mode 100644 index 0000000..a8daf97 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_20.jpg differ diff --git a/apps/web/app/assets/cover-images/image_21.jpg b/apps/web/app/assets/cover-images/image_21.jpg new file mode 100644 index 0000000..57c094e Binary files /dev/null and b/apps/web/app/assets/cover-images/image_21.jpg differ diff --git a/apps/web/app/assets/cover-images/image_22.jpg b/apps/web/app/assets/cover-images/image_22.jpg new file mode 100644 index 0000000..9efc564 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_22.jpg differ diff --git a/apps/web/app/assets/cover-images/image_23.jpg b/apps/web/app/assets/cover-images/image_23.jpg new file mode 100644 index 0000000..fec33d9 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_23.jpg differ diff --git a/apps/web/app/assets/cover-images/image_24.jpg b/apps/web/app/assets/cover-images/image_24.jpg new file mode 100644 index 0000000..54c74a6 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_24.jpg differ diff --git a/apps/web/app/assets/cover-images/image_25.jpg b/apps/web/app/assets/cover-images/image_25.jpg new file mode 100644 index 0000000..66841c0 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_25.jpg differ diff --git a/apps/web/app/assets/cover-images/image_26.jpg b/apps/web/app/assets/cover-images/image_26.jpg new file mode 100644 index 0000000..d0c2766 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_26.jpg differ diff --git a/apps/web/app/assets/cover-images/image_27.jpg b/apps/web/app/assets/cover-images/image_27.jpg new file mode 100644 index 0000000..84abce2 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_27.jpg differ diff --git a/apps/web/app/assets/cover-images/image_28.jpg b/apps/web/app/assets/cover-images/image_28.jpg new file mode 100644 index 0000000..0ce78e3 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_28.jpg differ diff --git a/apps/web/app/assets/cover-images/image_29.jpg b/apps/web/app/assets/cover-images/image_29.jpg new file mode 100644 index 0000000..9df7aa0 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_29.jpg differ diff --git a/apps/web/app/assets/cover-images/image_3.jpg b/apps/web/app/assets/cover-images/image_3.jpg new file mode 100644 index 0000000..4518492 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_3.jpg differ diff --git a/apps/web/app/assets/cover-images/image_4.jpg b/apps/web/app/assets/cover-images/image_4.jpg new file mode 100644 index 0000000..04d1109 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_4.jpg differ diff --git a/apps/web/app/assets/cover-images/image_5.jpg b/apps/web/app/assets/cover-images/image_5.jpg new file mode 100644 index 0000000..6dcdb25 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_5.jpg differ diff --git a/apps/web/app/assets/cover-images/image_6.jpg b/apps/web/app/assets/cover-images/image_6.jpg new file mode 100644 index 0000000..f1cb9bf Binary files /dev/null and b/apps/web/app/assets/cover-images/image_6.jpg differ diff --git a/apps/web/app/assets/cover-images/image_7.jpg b/apps/web/app/assets/cover-images/image_7.jpg new file mode 100644 index 0000000..c70602c Binary files /dev/null and b/apps/web/app/assets/cover-images/image_7.jpg differ diff --git a/apps/web/app/assets/cover-images/image_8.jpg b/apps/web/app/assets/cover-images/image_8.jpg new file mode 100644 index 0000000..508bd55 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_8.jpg differ diff --git a/apps/web/app/assets/cover-images/image_9.jpg b/apps/web/app/assets/cover-images/image_9.jpg new file mode 100644 index 0000000..d5267f9 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_9.jpg differ diff --git a/apps/web/app/assets/emoji/project-emoji.svg b/apps/web/app/assets/emoji/project-emoji.svg new file mode 100644 index 0000000..a2b9a62 --- /dev/null +++ b/apps/web/app/assets/emoji/project-emoji.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/app/assets/empty-state/active-cycle/assignee-dark.webp b/apps/web/app/assets/empty-state/active-cycle/assignee-dark.webp new file mode 100644 index 0000000..2590627 Binary files /dev/null and b/apps/web/app/assets/empty-state/active-cycle/assignee-dark.webp differ diff --git a/apps/web/app/assets/empty-state/active-cycle/assignee-light.webp b/apps/web/app/assets/empty-state/active-cycle/assignee-light.webp new file mode 100644 index 0000000..b8ae5c0 Binary files /dev/null and b/apps/web/app/assets/empty-state/active-cycle/assignee-light.webp differ diff --git a/apps/web/app/assets/empty-state/active-cycle/chart-dark.webp b/apps/web/app/assets/empty-state/active-cycle/chart-dark.webp new file mode 100644 index 0000000..033d6fe Binary files /dev/null and b/apps/web/app/assets/empty-state/active-cycle/chart-dark.webp differ diff --git a/apps/web/app/assets/empty-state/active-cycle/chart-light.webp b/apps/web/app/assets/empty-state/active-cycle/chart-light.webp new file mode 100644 index 0000000..587906f Binary files /dev/null and b/apps/web/app/assets/empty-state/active-cycle/chart-light.webp differ diff --git a/apps/web/app/assets/empty-state/active-cycle/cycle-dark.webp b/apps/web/app/assets/empty-state/active-cycle/cycle-dark.webp new file mode 100644 index 0000000..d092308 Binary files /dev/null and b/apps/web/app/assets/empty-state/active-cycle/cycle-dark.webp differ diff --git a/apps/web/app/assets/empty-state/active-cycle/cycle-light.webp b/apps/web/app/assets/empty-state/active-cycle/cycle-light.webp new file mode 100644 index 0000000..a218d4d Binary files /dev/null and b/apps/web/app/assets/empty-state/active-cycle/cycle-light.webp differ diff --git a/apps/web/app/assets/empty-state/active-cycle/label-dark.webp b/apps/web/app/assets/empty-state/active-cycle/label-dark.webp new file mode 100644 index 0000000..c15d3be Binary files /dev/null and b/apps/web/app/assets/empty-state/active-cycle/label-dark.webp differ diff --git a/apps/web/app/assets/empty-state/active-cycle/label-light.webp b/apps/web/app/assets/empty-state/active-cycle/label-light.webp new file mode 100644 index 0000000..e5ccc69 Binary files /dev/null and b/apps/web/app/assets/empty-state/active-cycle/label-light.webp differ diff --git a/apps/web/app/assets/empty-state/active-cycle/priority-dark.webp b/apps/web/app/assets/empty-state/active-cycle/priority-dark.webp new file mode 100644 index 0000000..f662e92 Binary files /dev/null and b/apps/web/app/assets/empty-state/active-cycle/priority-dark.webp differ diff --git a/apps/web/app/assets/empty-state/active-cycle/priority-light.webp b/apps/web/app/assets/empty-state/active-cycle/priority-light.webp new file mode 100644 index 0000000..fde814c Binary files /dev/null and b/apps/web/app/assets/empty-state/active-cycle/priority-light.webp differ diff --git a/apps/web/app/assets/empty-state/active-cycle/progress-dark.webp b/apps/web/app/assets/empty-state/active-cycle/progress-dark.webp new file mode 100644 index 0000000..4beda66 Binary files /dev/null and b/apps/web/app/assets/empty-state/active-cycle/progress-dark.webp differ diff --git a/apps/web/app/assets/empty-state/active-cycle/progress-light.webp b/apps/web/app/assets/empty-state/active-cycle/progress-light.webp new file mode 100644 index 0000000..ae30cf7 Binary files /dev/null and b/apps/web/app/assets/empty-state/active-cycle/progress-light.webp differ diff --git a/apps/web/app/assets/empty-state/all-issues/all-issues-dark.webp b/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/apps/web/app/assets/empty-state/all-issues/all-issues-dark.webp differ diff --git a/apps/web/app/assets/empty-state/all-issues/all-issues-light.webp b/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/apps/web/app/assets/empty-state/all-issues/all-issues-light.webp differ diff --git a/apps/web/app/assets/empty-state/all-issues/assigned-dark.webp b/apps/web/app/assets/empty-state/all-issues/assigned-dark.webp new file mode 100644 index 0000000..23bc60a Binary files /dev/null and b/apps/web/app/assets/empty-state/all-issues/assigned-dark.webp differ diff --git a/apps/web/app/assets/empty-state/all-issues/assigned-light.webp b/apps/web/app/assets/empty-state/all-issues/assigned-light.webp new file mode 100644 index 0000000..f48b9cf Binary files /dev/null and b/apps/web/app/assets/empty-state/all-issues/assigned-light.webp differ diff --git a/apps/web/app/assets/empty-state/all-issues/created-dark.webp b/apps/web/app/assets/empty-state/all-issues/created-dark.webp new file mode 100644 index 0000000..69d35fc Binary files /dev/null and b/apps/web/app/assets/empty-state/all-issues/created-dark.webp differ diff --git a/apps/web/app/assets/empty-state/all-issues/created-light.webp b/apps/web/app/assets/empty-state/all-issues/created-light.webp new file mode 100644 index 0000000..be4f67d Binary files /dev/null and b/apps/web/app/assets/empty-state/all-issues/created-light.webp differ diff --git a/apps/web/app/assets/empty-state/all-issues/custom-view-dark.webp b/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/apps/web/app/assets/empty-state/all-issues/custom-view-dark.webp differ diff --git a/apps/web/app/assets/empty-state/all-issues/custom-view-light.webp b/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/apps/web/app/assets/empty-state/all-issues/custom-view-light.webp differ diff --git a/apps/web/app/assets/empty-state/all-issues/no-project-dark.webp b/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/apps/web/app/assets/empty-state/all-issues/no-project-dark.webp differ diff --git a/apps/web/app/assets/empty-state/all-issues/no-project-light.webp b/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/apps/web/app/assets/empty-state/all-issues/no-project-light.webp differ diff --git a/apps/web/app/assets/empty-state/all-issues/subscribed-dark.webp b/apps/web/app/assets/empty-state/all-issues/subscribed-dark.webp new file mode 100644 index 0000000..68b3b30 Binary files /dev/null and b/apps/web/app/assets/empty-state/all-issues/subscribed-dark.webp differ diff --git a/apps/web/app/assets/empty-state/all-issues/subscribed-light.webp b/apps/web/app/assets/empty-state/all-issues/subscribed-light.webp new file mode 100644 index 0000000..4dbbedc Binary files /dev/null and b/apps/web/app/assets/empty-state/all-issues/subscribed-light.webp differ diff --git a/apps/web/app/assets/empty-state/analytics/empty-chart-area-dark.webp b/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/apps/web/app/assets/empty-state/analytics/empty-chart-area-dark.webp differ diff --git a/apps/web/app/assets/empty-state/analytics/empty-chart-area-light.webp b/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/apps/web/app/assets/empty-state/analytics/empty-chart-area-light.webp differ diff --git a/apps/web/app/assets/empty-state/analytics/empty-chart-bar-dark.webp b/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/apps/web/app/assets/empty-state/analytics/empty-chart-bar-dark.webp differ diff --git a/apps/web/app/assets/empty-state/analytics/empty-chart-bar-light.webp b/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/apps/web/app/assets/empty-state/analytics/empty-chart-bar-light.webp differ diff --git a/apps/web/app/assets/empty-state/analytics/empty-chart-radar-dark.webp b/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/apps/web/app/assets/empty-state/analytics/empty-chart-radar-dark.webp differ diff --git a/apps/web/app/assets/empty-state/analytics/empty-chart-radar-light.webp b/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/apps/web/app/assets/empty-state/analytics/empty-chart-radar-light.webp differ diff --git a/apps/web/app/assets/empty-state/analytics/empty-grid-background-dark.webp b/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/apps/web/app/assets/empty-state/analytics/empty-grid-background-dark.webp differ diff --git a/apps/web/app/assets/empty-state/analytics/empty-grid-background-light.webp b/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/apps/web/app/assets/empty-state/analytics/empty-grid-background-light.webp differ diff --git a/apps/web/app/assets/empty-state/analytics/empty-table-dark.webp b/apps/web/app/assets/empty-state/analytics/empty-table-dark.webp new file mode 100644 index 0000000..f5fcedd Binary files /dev/null and b/apps/web/app/assets/empty-state/analytics/empty-table-dark.webp differ diff --git a/apps/web/app/assets/empty-state/analytics/empty-table-light.webp b/apps/web/app/assets/empty-state/analytics/empty-table-light.webp new file mode 100644 index 0000000..c9e8fec Binary files /dev/null and b/apps/web/app/assets/empty-state/analytics/empty-table-light.webp differ diff --git a/apps/web/app/assets/empty-state/api-token.svg b/apps/web/app/assets/empty-state/api-token.svg new file mode 100644 index 0000000..f8e32d3 --- /dev/null +++ b/apps/web/app/assets/empty-state/api-token.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/archived/empty-cycles-dark.webp b/apps/web/app/assets/empty-state/archived/empty-cycles-dark.webp new file mode 100644 index 0000000..872fb2f Binary files /dev/null and b/apps/web/app/assets/empty-state/archived/empty-cycles-dark.webp differ diff --git a/apps/web/app/assets/empty-state/archived/empty-cycles-light.webp b/apps/web/app/assets/empty-state/archived/empty-cycles-light.webp new file mode 100644 index 0000000..2db1dc4 Binary files /dev/null and b/apps/web/app/assets/empty-state/archived/empty-cycles-light.webp differ diff --git a/apps/web/app/assets/empty-state/archived/empty-issues-dark.webp b/apps/web/app/assets/empty-state/archived/empty-issues-dark.webp new file mode 100644 index 0000000..c18e946 Binary files /dev/null and b/apps/web/app/assets/empty-state/archived/empty-issues-dark.webp differ diff --git a/apps/web/app/assets/empty-state/archived/empty-issues-light.webp b/apps/web/app/assets/empty-state/archived/empty-issues-light.webp new file mode 100644 index 0000000..d99a501 Binary files /dev/null and b/apps/web/app/assets/empty-state/archived/empty-issues-light.webp differ diff --git a/apps/web/app/assets/empty-state/archived/empty-modules-dark.webp b/apps/web/app/assets/empty-state/archived/empty-modules-dark.webp new file mode 100644 index 0000000..e34bf37 Binary files /dev/null and b/apps/web/app/assets/empty-state/archived/empty-modules-dark.webp differ diff --git a/apps/web/app/assets/empty-state/archived/empty-modules-light.webp b/apps/web/app/assets/empty-state/archived/empty-modules-light.webp new file mode 100644 index 0000000..5caf2ea Binary files /dev/null and b/apps/web/app/assets/empty-state/archived/empty-modules-light.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/calendar-dark-resp.webp b/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/apps/web/app/assets/empty-state/cycle-issues/calendar-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/calendar-dark.webp b/apps/web/app/assets/empty-state/cycle-issues/calendar-dark.webp new file mode 100644 index 0000000..312b138 Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle-issues/calendar-dark.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/calendar-light-resp.webp b/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/apps/web/app/assets/empty-state/cycle-issues/calendar-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/calendar-light.webp b/apps/web/app/assets/empty-state/cycle-issues/calendar-light.webp new file mode 100644 index 0000000..aef2ff1 Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle-issues/calendar-light.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark-resp.webp b/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/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark.webp b/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/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-dark.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light-resp.webp b/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/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light.webp b/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/apps/web/app/assets/empty-state/cycle-issues/gantt_chart-light.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/kanban-dark-resp.webp b/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/apps/web/app/assets/empty-state/cycle-issues/kanban-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/kanban-dark.webp b/apps/web/app/assets/empty-state/cycle-issues/kanban-dark.webp new file mode 100644 index 0000000..b161dbc Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle-issues/kanban-dark.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/kanban-light-resp.webp b/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/apps/web/app/assets/empty-state/cycle-issues/kanban-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/kanban-light.webp b/apps/web/app/assets/empty-state/cycle-issues/kanban-light.webp new file mode 100644 index 0000000..0958f21 Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle-issues/kanban-light.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/list-dark-resp.webp b/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/apps/web/app/assets/empty-state/cycle-issues/list-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/list-dark.webp b/apps/web/app/assets/empty-state/cycle-issues/list-dark.webp new file mode 100644 index 0000000..7e9f8c6 Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle-issues/list-dark.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/list-light-resp.webp b/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/apps/web/app/assets/empty-state/cycle-issues/list-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/list-light.webp b/apps/web/app/assets/empty-state/cycle-issues/list-light.webp new file mode 100644 index 0000000..fb01bff Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle-issues/list-light.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark-resp.webp b/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/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark.webp b/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark.webp new file mode 100644 index 0000000..6f60361 Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-dark.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light-resp.webp b/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/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light.webp b/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light.webp new file mode 100644 index 0000000..0f35b4e Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle-issues/spreadsheet-light.webp differ diff --git a/apps/web/app/assets/empty-state/cycle.svg b/apps/web/app/assets/empty-state/cycle.svg new file mode 100644 index 0000000..fe1dba3 --- /dev/null +++ b/apps/web/app/assets/empty-state/cycle.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/cycle/active-dark.webp b/apps/web/app/assets/empty-state/cycle/active-dark.webp new file mode 100644 index 0000000..68317b1 Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle/active-dark.webp differ diff --git a/apps/web/app/assets/empty-state/cycle/active-light.webp b/apps/web/app/assets/empty-state/cycle/active-light.webp new file mode 100644 index 0000000..2341a90 Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle/active-light.webp differ diff --git a/apps/web/app/assets/empty-state/cycle/all-filters.svg b/apps/web/app/assets/empty-state/cycle/all-filters.svg new file mode 100644 index 0000000..b00841c --- /dev/null +++ b/apps/web/app/assets/empty-state/cycle/all-filters.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/cycle/completed-dark.webp b/apps/web/app/assets/empty-state/cycle/completed-dark.webp new file mode 100644 index 0000000..eebe656 Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle/completed-dark.webp differ diff --git a/apps/web/app/assets/empty-state/cycle/completed-light.webp b/apps/web/app/assets/empty-state/cycle/completed-light.webp new file mode 100644 index 0000000..6c192fd Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle/completed-light.webp differ diff --git a/apps/web/app/assets/empty-state/cycle/completed-no-issues-dark.webp b/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/apps/web/app/assets/empty-state/cycle/completed-no-issues-dark.webp differ diff --git a/apps/web/app/assets/empty-state/cycle/completed-no-issues-light.webp b/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/apps/web/app/assets/empty-state/cycle/completed-no-issues-light.webp differ diff --git a/apps/web/app/assets/empty-state/cycle/draft-dark.webp b/apps/web/app/assets/empty-state/cycle/draft-dark.webp new file mode 100644 index 0000000..887e2e9 Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle/draft-dark.webp differ diff --git a/apps/web/app/assets/empty-state/cycle/draft-light.webp b/apps/web/app/assets/empty-state/cycle/draft-light.webp new file mode 100644 index 0000000..d1c88bc Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle/draft-light.webp differ diff --git a/apps/web/app/assets/empty-state/cycle/name-filter.svg b/apps/web/app/assets/empty-state/cycle/name-filter.svg new file mode 100644 index 0000000..1686111 --- /dev/null +++ b/apps/web/app/assets/empty-state/cycle/name-filter.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/cycle/upcoming-dark.webp b/apps/web/app/assets/empty-state/cycle/upcoming-dark.webp new file mode 100644 index 0000000..205164f Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle/upcoming-dark.webp differ diff --git a/apps/web/app/assets/empty-state/cycle/upcoming-light.webp b/apps/web/app/assets/empty-state/cycle/upcoming-light.webp new file mode 100644 index 0000000..522122f Binary files /dev/null and b/apps/web/app/assets/empty-state/cycle/upcoming-light.webp differ diff --git a/apps/web/app/assets/empty-state/dashboard/dark/completed-issues.svg b/apps/web/app/assets/empty-state/dashboard/dark/completed-issues.svg new file mode 100644 index 0000000..8c4d083 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/dark/completed-issues.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/dark/issues-by-priority.svg b/apps/web/app/assets/empty-state/dashboard/dark/issues-by-priority.svg new file mode 100644 index 0000000..803f3a7 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/dark/issues-by-priority.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/dark/issues-by-state-group.svg b/apps/web/app/assets/empty-state/dashboard/dark/issues-by-state-group.svg new file mode 100644 index 0000000..b615c31 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/dark/issues-by-state-group.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/dark/overdue-issues.svg b/apps/web/app/assets/empty-state/dashboard/dark/overdue-issues.svg new file mode 100644 index 0000000..cceb348 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/dark/overdue-issues.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/dark/recent-activity.svg b/apps/web/app/assets/empty-state/dashboard/dark/recent-activity.svg new file mode 100644 index 0000000..bf72348 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/dark/recent-activity.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-1.svg b/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-1.svg new file mode 100644 index 0000000..341364f --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-2.svg b/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-2.svg new file mode 100644 index 0000000..6889f75 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-3.svg b/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-3.svg new file mode 100644 index 0000000..deb0e21 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/dark/recent-collaborators-3.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/dark/upcoming-issues.svg b/apps/web/app/assets/empty-state/dashboard/dark/upcoming-issues.svg new file mode 100644 index 0000000..2559945 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/dark/upcoming-issues.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/light/completed-issues.svg b/apps/web/app/assets/empty-state/dashboard/light/completed-issues.svg new file mode 100644 index 0000000..6f9c4c4 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/light/completed-issues.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/light/issues-by-priority.svg b/apps/web/app/assets/empty-state/dashboard/light/issues-by-priority.svg new file mode 100644 index 0000000..23c35d9 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/light/issues-by-priority.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/light/issues-by-state-group.svg b/apps/web/app/assets/empty-state/dashboard/light/issues-by-state-group.svg new file mode 100644 index 0000000..eb32753 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/light/issues-by-state-group.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/light/overdue-issues.svg b/apps/web/app/assets/empty-state/dashboard/light/overdue-issues.svg new file mode 100644 index 0000000..1c66ddd --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/light/overdue-issues.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/light/recent-activity.svg b/apps/web/app/assets/empty-state/dashboard/light/recent-activity.svg new file mode 100644 index 0000000..3bb6749 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/light/recent-activity.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-1.svg b/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-1.svg new file mode 100644 index 0000000..fb77e41 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-2.svg b/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-2.svg new file mode 100644 index 0000000..e25fc95 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-3.svg b/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-3.svg new file mode 100644 index 0000000..e46473e --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/light/recent-collaborators-3.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/light/upcoming-issues.svg b/apps/web/app/assets/empty-state/dashboard/light/upcoming-issues.svg new file mode 100644 index 0000000..ad30358 --- /dev/null +++ b/apps/web/app/assets/empty-state/dashboard/light/upcoming-issues.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/dashboard/widgets-dark.webp b/apps/web/app/assets/empty-state/dashboard/widgets-dark.webp new file mode 100644 index 0000000..3a626f7 Binary files /dev/null and b/apps/web/app/assets/empty-state/dashboard/widgets-dark.webp differ diff --git a/apps/web/app/assets/empty-state/dashboard/widgets-light.webp b/apps/web/app/assets/empty-state/dashboard/widgets-light.webp new file mode 100644 index 0000000..6161c57 Binary files /dev/null and b/apps/web/app/assets/empty-state/dashboard/widgets-light.webp differ diff --git a/apps/web/app/assets/empty-state/dashboard_empty_project.webp b/apps/web/app/assets/empty-state/dashboard_empty_project.webp new file mode 100644 index 0000000..c1f633f Binary files /dev/null and b/apps/web/app/assets/empty-state/dashboard_empty_project.webp differ diff --git a/apps/web/app/assets/empty-state/disabled-feature/cycles-dark.webp b/apps/web/app/assets/empty-state/disabled-feature/cycles-dark.webp new file mode 100644 index 0000000..5897062 Binary files /dev/null and b/apps/web/app/assets/empty-state/disabled-feature/cycles-dark.webp differ diff --git a/apps/web/app/assets/empty-state/disabled-feature/cycles-light.webp b/apps/web/app/assets/empty-state/disabled-feature/cycles-light.webp new file mode 100644 index 0000000..c13d082 Binary files /dev/null and b/apps/web/app/assets/empty-state/disabled-feature/cycles-light.webp differ diff --git a/apps/web/app/assets/empty-state/disabled-feature/intake-dark.webp b/apps/web/app/assets/empty-state/disabled-feature/intake-dark.webp new file mode 100644 index 0000000..9b459b8 Binary files /dev/null and b/apps/web/app/assets/empty-state/disabled-feature/intake-dark.webp differ diff --git a/apps/web/app/assets/empty-state/disabled-feature/intake-light.webp b/apps/web/app/assets/empty-state/disabled-feature/intake-light.webp new file mode 100644 index 0000000..70757be Binary files /dev/null and b/apps/web/app/assets/empty-state/disabled-feature/intake-light.webp differ diff --git a/apps/web/app/assets/empty-state/disabled-feature/modules-dark.webp b/apps/web/app/assets/empty-state/disabled-feature/modules-dark.webp new file mode 100644 index 0000000..8e198ed Binary files /dev/null and b/apps/web/app/assets/empty-state/disabled-feature/modules-dark.webp differ diff --git a/apps/web/app/assets/empty-state/disabled-feature/modules-light.webp b/apps/web/app/assets/empty-state/disabled-feature/modules-light.webp new file mode 100644 index 0000000..d1db65c Binary files /dev/null and b/apps/web/app/assets/empty-state/disabled-feature/modules-light.webp differ diff --git a/apps/web/app/assets/empty-state/disabled-feature/pages-dark.webp b/apps/web/app/assets/empty-state/disabled-feature/pages-dark.webp new file mode 100644 index 0000000..19263a0 Binary files /dev/null and b/apps/web/app/assets/empty-state/disabled-feature/pages-dark.webp differ diff --git a/apps/web/app/assets/empty-state/disabled-feature/pages-light.webp b/apps/web/app/assets/empty-state/disabled-feature/pages-light.webp new file mode 100644 index 0000000..fd1c5b4 Binary files /dev/null and b/apps/web/app/assets/empty-state/disabled-feature/pages-light.webp differ diff --git a/apps/web/app/assets/empty-state/disabled-feature/views-dark.webp b/apps/web/app/assets/empty-state/disabled-feature/views-dark.webp new file mode 100644 index 0000000..bfdb573 Binary files /dev/null and b/apps/web/app/assets/empty-state/disabled-feature/views-dark.webp differ diff --git a/apps/web/app/assets/empty-state/disabled-feature/views-light.webp b/apps/web/app/assets/empty-state/disabled-feature/views-light.webp new file mode 100644 index 0000000..e318e27 Binary files /dev/null and b/apps/web/app/assets/empty-state/disabled-feature/views-light.webp differ diff --git a/apps/web/app/assets/empty-state/draft/draft-issues-empty-dark.webp b/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/apps/web/app/assets/empty-state/draft/draft-issues-empty-dark.webp differ diff --git a/apps/web/app/assets/empty-state/draft/draft-issues-empty-light.webp b/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/apps/web/app/assets/empty-state/draft/draft-issues-empty-light.webp differ diff --git a/apps/web/app/assets/empty-state/empty-filters/calendar-dark.webp b/apps/web/app/assets/empty-state/empty-filters/calendar-dark.webp new file mode 100644 index 0000000..872be08 Binary files /dev/null and b/apps/web/app/assets/empty-state/empty-filters/calendar-dark.webp differ diff --git a/apps/web/app/assets/empty-state/empty-filters/calendar-light.webp b/apps/web/app/assets/empty-state/empty-filters/calendar-light.webp new file mode 100644 index 0000000..50e56eb Binary files /dev/null and b/apps/web/app/assets/empty-state/empty-filters/calendar-light.webp differ diff --git a/apps/web/app/assets/empty-state/empty-filters/gantt_chart-dark.webp b/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/apps/web/app/assets/empty-state/empty-filters/gantt_chart-dark.webp differ diff --git a/apps/web/app/assets/empty-state/empty-filters/gantt_chart-light.webp b/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/apps/web/app/assets/empty-state/empty-filters/gantt_chart-light.webp differ diff --git a/apps/web/app/assets/empty-state/empty-filters/kanban-dark.webp b/apps/web/app/assets/empty-state/empty-filters/kanban-dark.webp new file mode 100644 index 0000000..a368c68 Binary files /dev/null and b/apps/web/app/assets/empty-state/empty-filters/kanban-dark.webp differ diff --git a/apps/web/app/assets/empty-state/empty-filters/kanban-light.webp b/apps/web/app/assets/empty-state/empty-filters/kanban-light.webp new file mode 100644 index 0000000..50507e7 Binary files /dev/null and b/apps/web/app/assets/empty-state/empty-filters/kanban-light.webp differ diff --git a/apps/web/app/assets/empty-state/empty-filters/list-dark.webp b/apps/web/app/assets/empty-state/empty-filters/list-dark.webp new file mode 100644 index 0000000..6c49f29 Binary files /dev/null and b/apps/web/app/assets/empty-state/empty-filters/list-dark.webp differ diff --git a/apps/web/app/assets/empty-state/empty-filters/list-light.webp b/apps/web/app/assets/empty-state/empty-filters/list-light.webp new file mode 100644 index 0000000..bc4f284 Binary files /dev/null and b/apps/web/app/assets/empty-state/empty-filters/list-light.webp differ diff --git a/apps/web/app/assets/empty-state/empty-filters/spreadsheet-dark.webp b/apps/web/app/assets/empty-state/empty-filters/spreadsheet-dark.webp new file mode 100644 index 0000000..2e9ff4c Binary files /dev/null and b/apps/web/app/assets/empty-state/empty-filters/spreadsheet-dark.webp differ diff --git a/apps/web/app/assets/empty-state/empty-filters/spreadsheet-light.webp b/apps/web/app/assets/empty-state/empty-filters/spreadsheet-light.webp new file mode 100644 index 0000000..174b3d0 Binary files /dev/null and b/apps/web/app/assets/empty-state/empty-filters/spreadsheet-light.webp differ diff --git a/apps/web/app/assets/empty-state/empty-updates-light.png b/apps/web/app/assets/empty-state/empty-updates-light.png new file mode 100644 index 0000000..a92c093 Binary files /dev/null and b/apps/web/app/assets/empty-state/empty-updates-light.png differ diff --git a/apps/web/app/assets/empty-state/empty_analytics.webp b/apps/web/app/assets/empty-state/empty_analytics.webp new file mode 100644 index 0000000..46174ff Binary files /dev/null and b/apps/web/app/assets/empty-state/empty_analytics.webp differ diff --git a/apps/web/app/assets/empty-state/empty_bar_graph.svg b/apps/web/app/assets/empty-state/empty_bar_graph.svg new file mode 100644 index 0000000..7742a42 --- /dev/null +++ b/apps/web/app/assets/empty-state/empty_bar_graph.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/app/assets/empty-state/empty_cycles.webp b/apps/web/app/assets/empty-state/empty_cycles.webp new file mode 100644 index 0000000..710ae2d Binary files /dev/null and b/apps/web/app/assets/empty-state/empty_cycles.webp differ diff --git a/apps/web/app/assets/empty-state/empty_graph.svg b/apps/web/app/assets/empty-state/empty_graph.svg new file mode 100644 index 0000000..5eae523 --- /dev/null +++ b/apps/web/app/assets/empty-state/empty_graph.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/empty_issues.webp b/apps/web/app/assets/empty-state/empty_issues.webp new file mode 100644 index 0000000..e60d663 Binary files /dev/null and b/apps/web/app/assets/empty-state/empty_issues.webp differ diff --git a/apps/web/app/assets/empty-state/empty_label.svg b/apps/web/app/assets/empty-state/empty_label.svg new file mode 100644 index 0000000..c664da6 --- /dev/null +++ b/apps/web/app/assets/empty-state/empty_label.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/app/assets/empty-state/empty_members.svg b/apps/web/app/assets/empty-state/empty_members.svg new file mode 100644 index 0000000..6672c58 --- /dev/null +++ b/apps/web/app/assets/empty-state/empty_members.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/empty_modules.webp b/apps/web/app/assets/empty-state/empty_modules.webp new file mode 100644 index 0000000..49050b5 Binary files /dev/null and b/apps/web/app/assets/empty-state/empty_modules.webp differ diff --git a/apps/web/app/assets/empty-state/empty_page.png b/apps/web/app/assets/empty-state/empty_page.png new file mode 100644 index 0000000..993774c Binary files /dev/null and b/apps/web/app/assets/empty-state/empty_page.png differ diff --git a/apps/web/app/assets/empty-state/empty_project.webp b/apps/web/app/assets/empty-state/empty_project.webp new file mode 100644 index 0000000..ad66f09 Binary files /dev/null and b/apps/web/app/assets/empty-state/empty_project.webp differ diff --git a/apps/web/app/assets/empty-state/empty_users.svg b/apps/web/app/assets/empty-state/empty_users.svg new file mode 100644 index 0000000..7298e9e --- /dev/null +++ b/apps/web/app/assets/empty-state/empty_users.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/app/assets/empty-state/empty_view.webp b/apps/web/app/assets/empty-state/empty_view.webp new file mode 100644 index 0000000..aa0f5a6 Binary files /dev/null and b/apps/web/app/assets/empty-state/empty_view.webp differ diff --git a/apps/web/app/assets/empty-state/epics/epics-dark.webp b/apps/web/app/assets/empty-state/epics/epics-dark.webp new file mode 100644 index 0000000..648392a Binary files /dev/null and b/apps/web/app/assets/empty-state/epics/epics-dark.webp differ diff --git a/apps/web/app/assets/empty-state/epics/epics-light.webp b/apps/web/app/assets/empty-state/epics/epics-light.webp new file mode 100644 index 0000000..f677e5d Binary files /dev/null and b/apps/web/app/assets/empty-state/epics/epics-light.webp differ diff --git a/apps/web/app/assets/empty-state/epics/settings-dark.webp b/apps/web/app/assets/empty-state/epics/settings-dark.webp new file mode 100644 index 0000000..3193137 Binary files /dev/null and b/apps/web/app/assets/empty-state/epics/settings-dark.webp differ diff --git a/apps/web/app/assets/empty-state/epics/settings-light.webp b/apps/web/app/assets/empty-state/epics/settings-light.webp new file mode 100644 index 0000000..1f9596e Binary files /dev/null and b/apps/web/app/assets/empty-state/epics/settings-light.webp differ diff --git a/apps/web/app/assets/empty-state/estimates/dark.svg b/apps/web/app/assets/empty-state/estimates/dark.svg new file mode 100644 index 0000000..2f5d758 --- /dev/null +++ b/apps/web/app/assets/empty-state/estimates/dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/estimates/light.svg b/apps/web/app/assets/empty-state/estimates/light.svg new file mode 100644 index 0000000..817b9fb --- /dev/null +++ b/apps/web/app/assets/empty-state/estimates/light.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/intake/filter-issue-dark.webp b/apps/web/app/assets/empty-state/intake/filter-issue-dark.webp new file mode 100644 index 0000000..dab3eb7 Binary files /dev/null and b/apps/web/app/assets/empty-state/intake/filter-issue-dark.webp differ diff --git a/apps/web/app/assets/empty-state/intake/filter-issue-light.webp b/apps/web/app/assets/empty-state/intake/filter-issue-light.webp new file mode 100644 index 0000000..6716706 Binary files /dev/null and b/apps/web/app/assets/empty-state/intake/filter-issue-light.webp differ diff --git a/apps/web/app/assets/empty-state/intake/intake-dark-resp.webp b/apps/web/app/assets/empty-state/intake/intake-dark-resp.webp new file mode 100644 index 0000000..bf6166e Binary files /dev/null and b/apps/web/app/assets/empty-state/intake/intake-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/intake/intake-dark.webp b/apps/web/app/assets/empty-state/intake/intake-dark.webp new file mode 100644 index 0000000..9b459b8 Binary files /dev/null and b/apps/web/app/assets/empty-state/intake/intake-dark.webp differ diff --git a/apps/web/app/assets/empty-state/intake/intake-issue-dark.webp b/apps/web/app/assets/empty-state/intake/intake-issue-dark.webp new file mode 100644 index 0000000..0c14ce2 Binary files /dev/null and b/apps/web/app/assets/empty-state/intake/intake-issue-dark.webp differ diff --git a/apps/web/app/assets/empty-state/intake/intake-issue-light.webp b/apps/web/app/assets/empty-state/intake/intake-issue-light.webp new file mode 100644 index 0000000..c557f5d Binary files /dev/null and b/apps/web/app/assets/empty-state/intake/intake-issue-light.webp differ diff --git a/apps/web/app/assets/empty-state/intake/intake-light-resp.webp b/apps/web/app/assets/empty-state/intake/intake-light-resp.webp new file mode 100644 index 0000000..69a79d6 Binary files /dev/null and b/apps/web/app/assets/empty-state/intake/intake-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/intake/intake-light.webp b/apps/web/app/assets/empty-state/intake/intake-light.webp new file mode 100644 index 0000000..70757be Binary files /dev/null and b/apps/web/app/assets/empty-state/intake/intake-light.webp differ diff --git a/apps/web/app/assets/empty-state/intake/issue-detail-dark.webp b/apps/web/app/assets/empty-state/intake/issue-detail-dark.webp new file mode 100644 index 0000000..2a2c706 Binary files /dev/null and b/apps/web/app/assets/empty-state/intake/issue-detail-dark.webp differ diff --git a/apps/web/app/assets/empty-state/intake/issue-detail-light.webp b/apps/web/app/assets/empty-state/intake/issue-detail-light.webp new file mode 100644 index 0000000..a713069 Binary files /dev/null and b/apps/web/app/assets/empty-state/intake/issue-detail-light.webp differ diff --git a/apps/web/app/assets/empty-state/invitation.svg b/apps/web/app/assets/empty-state/invitation.svg new file mode 100644 index 0000000..bda66da --- /dev/null +++ b/apps/web/app/assets/empty-state/invitation.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/issue.svg b/apps/web/app/assets/empty-state/issue.svg new file mode 100644 index 0000000..98a420d --- /dev/null +++ b/apps/web/app/assets/empty-state/issue.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/label.svg b/apps/web/app/assets/empty-state/label.svg new file mode 100644 index 0000000..55241e2 --- /dev/null +++ b/apps/web/app/assets/empty-state/label.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/module-issues/calendar-dark-resp.webp b/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/apps/web/app/assets/empty-state/module-issues/calendar-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/calendar-dark.webp b/apps/web/app/assets/empty-state/module-issues/calendar-dark.webp new file mode 100644 index 0000000..5170f6c Binary files /dev/null and b/apps/web/app/assets/empty-state/module-issues/calendar-dark.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/calendar-light-resp.webp b/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/apps/web/app/assets/empty-state/module-issues/calendar-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/calendar-light.webp b/apps/web/app/assets/empty-state/module-issues/calendar-light.webp new file mode 100644 index 0000000..5bee9c9 Binary files /dev/null and b/apps/web/app/assets/empty-state/module-issues/calendar-light.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/gantt_chart-dark-resp.webp b/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/apps/web/app/assets/empty-state/module-issues/gantt_chart-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/gantt_chart-dark.webp b/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/apps/web/app/assets/empty-state/module-issues/gantt_chart-dark.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/gantt_chart-light-resp.webp b/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/apps/web/app/assets/empty-state/module-issues/gantt_chart-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/gantt_chart-light.webp b/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/apps/web/app/assets/empty-state/module-issues/gantt_chart-light.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/kanban-dark-resp.webp b/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/apps/web/app/assets/empty-state/module-issues/kanban-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/kanban-dark.webp b/apps/web/app/assets/empty-state/module-issues/kanban-dark.webp new file mode 100644 index 0000000..5e978d5 Binary files /dev/null and b/apps/web/app/assets/empty-state/module-issues/kanban-dark.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/kanban-light-resp.webp b/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/apps/web/app/assets/empty-state/module-issues/kanban-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/kanban-light.webp b/apps/web/app/assets/empty-state/module-issues/kanban-light.webp new file mode 100644 index 0000000..5d30a65 Binary files /dev/null and b/apps/web/app/assets/empty-state/module-issues/kanban-light.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/list-dark-resp.webp b/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/apps/web/app/assets/empty-state/module-issues/list-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/list-dark.webp b/apps/web/app/assets/empty-state/module-issues/list-dark.webp new file mode 100644 index 0000000..96e9518 Binary files /dev/null and b/apps/web/app/assets/empty-state/module-issues/list-dark.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/list-light-resp.webp b/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/apps/web/app/assets/empty-state/module-issues/list-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/list-light.webp b/apps/web/app/assets/empty-state/module-issues/list-light.webp new file mode 100644 index 0000000..d9b1c4c Binary files /dev/null and b/apps/web/app/assets/empty-state/module-issues/list-light.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark-resp.webp b/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/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark.webp b/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark.webp new file mode 100644 index 0000000..1894cfe Binary files /dev/null and b/apps/web/app/assets/empty-state/module-issues/spreadsheet-dark.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/spreadsheet-light-resp.webp b/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/apps/web/app/assets/empty-state/module-issues/spreadsheet-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/module-issues/spreadsheet-light.webp b/apps/web/app/assets/empty-state/module-issues/spreadsheet-light.webp new file mode 100644 index 0000000..aa3813f Binary files /dev/null and b/apps/web/app/assets/empty-state/module-issues/spreadsheet-light.webp differ diff --git a/apps/web/app/assets/empty-state/module.svg b/apps/web/app/assets/empty-state/module.svg new file mode 100644 index 0000000..359b3e9 --- /dev/null +++ b/apps/web/app/assets/empty-state/module.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/module/all-filters.svg b/apps/web/app/assets/empty-state/module/all-filters.svg new file mode 100644 index 0000000..6ba0731 --- /dev/null +++ b/apps/web/app/assets/empty-state/module/all-filters.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/module/name-filter.svg b/apps/web/app/assets/empty-state/module/name-filter.svg new file mode 100644 index 0000000..0d9655b --- /dev/null +++ b/apps/web/app/assets/empty-state/module/name-filter.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/notification.svg b/apps/web/app/assets/empty-state/notification.svg new file mode 100644 index 0000000..700a155 --- /dev/null +++ b/apps/web/app/assets/empty-state/notification.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/app/assets/empty-state/onboarding/analytics-dark.webp b/apps/web/app/assets/empty-state/onboarding/analytics-dark.webp new file mode 100644 index 0000000..a83d55d Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/analytics-dark.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/analytics-light.webp b/apps/web/app/assets/empty-state/onboarding/analytics-light.webp new file mode 100644 index 0000000..64a762f Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/analytics-light.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/archive-dark.png b/apps/web/app/assets/empty-state/onboarding/archive-dark.png new file mode 100644 index 0000000..54cca36 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/archive-dark.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/archive-light.png b/apps/web/app/assets/empty-state/onboarding/archive-light.png new file mode 100644 index 0000000..8530bba Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/archive-light.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/cycles-dark.webp b/apps/web/app/assets/empty-state/onboarding/cycles-dark.webp new file mode 100644 index 0000000..716f121 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/cycles-dark.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/cycles-light.webp b/apps/web/app/assets/empty-state/onboarding/cycles-light.webp new file mode 100644 index 0000000..0b2b400 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/cycles-light.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/dashboard-dark.webp b/apps/web/app/assets/empty-state/onboarding/dashboard-dark.webp new file mode 100644 index 0000000..afa10be Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/dashboard-dark.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/dashboard-light.webp b/apps/web/app/assets/empty-state/onboarding/dashboard-light.webp new file mode 100644 index 0000000..d4bce8b Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/dashboard-light.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/graph-dark.png b/apps/web/app/assets/empty-state/onboarding/graph-dark.png new file mode 100644 index 0000000..e8a938d Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/graph-dark.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/graph-light.png b/apps/web/app/assets/empty-state/onboarding/graph-light.png new file mode 100644 index 0000000..eb48b45 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/graph-light.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/issues-closed-dark.png b/apps/web/app/assets/empty-state/onboarding/issues-closed-dark.png new file mode 100644 index 0000000..d4bafb2 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/issues-closed-dark.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/issues-closed-light.png b/apps/web/app/assets/empty-state/onboarding/issues-closed-light.png new file mode 100644 index 0000000..739acd1 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/issues-closed-light.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/issues-dark.webp b/apps/web/app/assets/empty-state/onboarding/issues-dark.webp new file mode 100644 index 0000000..39ffb5c Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/issues-dark.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/issues-light.webp b/apps/web/app/assets/empty-state/onboarding/issues-light.webp new file mode 100644 index 0000000..18d42b1 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/issues-light.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/members-dark.png b/apps/web/app/assets/empty-state/onboarding/members-dark.png new file mode 100644 index 0000000..2f8a1c8 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/members-dark.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/members-light.png b/apps/web/app/assets/empty-state/onboarding/members-light.png new file mode 100644 index 0000000..eaa43ef Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/members-light.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/modules-dark.webp b/apps/web/app/assets/empty-state/onboarding/modules-dark.webp new file mode 100644 index 0000000..d855df0 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/modules-dark.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/modules-light.webp b/apps/web/app/assets/empty-state/onboarding/modules-light.webp new file mode 100644 index 0000000..ff3e6ac Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/modules-light.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/notification-dark.png b/apps/web/app/assets/empty-state/onboarding/notification-dark.png new file mode 100644 index 0000000..dee284c Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/notification-dark.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/notification-light.png b/apps/web/app/assets/empty-state/onboarding/notification-light.png new file mode 100644 index 0000000..3065787 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/notification-light.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/pages-dark.webp b/apps/web/app/assets/empty-state/onboarding/pages-dark.webp new file mode 100644 index 0000000..bc59270 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/pages-dark.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/pages-light.webp b/apps/web/app/assets/empty-state/onboarding/pages-light.webp new file mode 100644 index 0000000..1e28c0d Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/pages-light.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/projects-dark.webp b/apps/web/app/assets/empty-state/onboarding/projects-dark.webp new file mode 100644 index 0000000..d4f6c62 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/projects-dark.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/projects-light.webp b/apps/web/app/assets/empty-state/onboarding/projects-light.webp new file mode 100644 index 0000000..c475c8f Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/projects-light.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/search-dark.png b/apps/web/app/assets/empty-state/onboarding/search-dark.png new file mode 100644 index 0000000..531081d Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/search-dark.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/search-light.png b/apps/web/app/assets/empty-state/onboarding/search-light.png new file mode 100644 index 0000000..39e2636 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/search-light.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/snooze-light.png b/apps/web/app/assets/empty-state/onboarding/snooze-light.png new file mode 100644 index 0000000..6ccd7ab Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/snooze-light.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/snoozed-dark.png b/apps/web/app/assets/empty-state/onboarding/snoozed-dark.png new file mode 100644 index 0000000..c8b88fe Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/snoozed-dark.png differ diff --git a/apps/web/app/assets/empty-state/onboarding/views-dark.webp b/apps/web/app/assets/empty-state/onboarding/views-dark.webp new file mode 100644 index 0000000..0a7fe7b Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/views-dark.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/views-light.webp b/apps/web/app/assets/empty-state/onboarding/views-light.webp new file mode 100644 index 0000000..cfb2031 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/views-light.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-dark.webp b/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/apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-dark.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-light.webp b/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/apps/web/app/assets/empty-state/onboarding/workspace-active-cycles-light.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/workspace-invites-dark.webp b/apps/web/app/assets/empty-state/onboarding/workspace-invites-dark.webp new file mode 100644 index 0000000..d6e1004 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/workspace-invites-dark.webp differ diff --git a/apps/web/app/assets/empty-state/onboarding/workspace-invites-light.webp b/apps/web/app/assets/empty-state/onboarding/workspace-invites-light.webp new file mode 100644 index 0000000..123a600 Binary files /dev/null and b/apps/web/app/assets/empty-state/onboarding/workspace-invites-light.webp differ diff --git a/apps/web/app/assets/empty-state/profile/activities-dark.webp b/apps/web/app/assets/empty-state/profile/activities-dark.webp new file mode 100644 index 0000000..1b69394 Binary files /dev/null and b/apps/web/app/assets/empty-state/profile/activities-dark.webp differ diff --git a/apps/web/app/assets/empty-state/profile/activities-light.webp b/apps/web/app/assets/empty-state/profile/activities-light.webp new file mode 100644 index 0000000..c287e0c Binary files /dev/null and b/apps/web/app/assets/empty-state/profile/activities-light.webp differ diff --git a/apps/web/app/assets/empty-state/profile/activity-dark.webp b/apps/web/app/assets/empty-state/profile/activity-dark.webp new file mode 100644 index 0000000..d44e6a1 Binary files /dev/null and b/apps/web/app/assets/empty-state/profile/activity-dark.webp differ diff --git a/apps/web/app/assets/empty-state/profile/activity-light.webp b/apps/web/app/assets/empty-state/profile/activity-light.webp new file mode 100644 index 0000000..206685a Binary files /dev/null and b/apps/web/app/assets/empty-state/profile/activity-light.webp differ diff --git a/apps/web/app/assets/empty-state/profile/assigned-dark.webp b/apps/web/app/assets/empty-state/profile/assigned-dark.webp new file mode 100644 index 0000000..173ebe6 Binary files /dev/null and b/apps/web/app/assets/empty-state/profile/assigned-dark.webp differ diff --git a/apps/web/app/assets/empty-state/profile/assigned-light.webp b/apps/web/app/assets/empty-state/profile/assigned-light.webp new file mode 100644 index 0000000..78416c9 Binary files /dev/null and b/apps/web/app/assets/empty-state/profile/assigned-light.webp differ diff --git a/apps/web/app/assets/empty-state/profile/created-dark.webp b/apps/web/app/assets/empty-state/profile/created-dark.webp new file mode 100644 index 0000000..8232ce2 Binary files /dev/null and b/apps/web/app/assets/empty-state/profile/created-dark.webp differ diff --git a/apps/web/app/assets/empty-state/profile/created-light.webp b/apps/web/app/assets/empty-state/profile/created-light.webp new file mode 100644 index 0000000..4ea037a Binary files /dev/null and b/apps/web/app/assets/empty-state/profile/created-light.webp differ diff --git a/apps/web/app/assets/empty-state/profile/issues-by-priority-dark.webp b/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/apps/web/app/assets/empty-state/profile/issues-by-priority-dark.webp differ diff --git a/apps/web/app/assets/empty-state/profile/issues-by-priority-light.webp b/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/apps/web/app/assets/empty-state/profile/issues-by-priority-light.webp differ diff --git a/apps/web/app/assets/empty-state/profile/issues-by-state-dark.webp b/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/apps/web/app/assets/empty-state/profile/issues-by-state-dark.webp differ diff --git a/apps/web/app/assets/empty-state/profile/issues-by-state-light.webp b/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/apps/web/app/assets/empty-state/profile/issues-by-state-light.webp differ diff --git a/apps/web/app/assets/empty-state/profile/subscribed-dark.webp b/apps/web/app/assets/empty-state/profile/subscribed-dark.webp new file mode 100644 index 0000000..26837e1 Binary files /dev/null and b/apps/web/app/assets/empty-state/profile/subscribed-dark.webp differ diff --git a/apps/web/app/assets/empty-state/profile/subscribed-light.webp b/apps/web/app/assets/empty-state/profile/subscribed-light.webp new file mode 100644 index 0000000..7faf660 Binary files /dev/null and b/apps/web/app/assets/empty-state/profile/subscribed-light.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/estimates-dark-resp.webp b/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/apps/web/app/assets/empty-state/project-settings/estimates-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/estimates-dark.png b/apps/web/app/assets/empty-state/project-settings/estimates-dark.png new file mode 100644 index 0000000..9cc5346 Binary files /dev/null and b/apps/web/app/assets/empty-state/project-settings/estimates-dark.png differ diff --git a/apps/web/app/assets/empty-state/project-settings/estimates-dark.webp b/apps/web/app/assets/empty-state/project-settings/estimates-dark.webp new file mode 100644 index 0000000..35d42e5 Binary files /dev/null and b/apps/web/app/assets/empty-state/project-settings/estimates-dark.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/estimates-light-resp.webp b/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/apps/web/app/assets/empty-state/project-settings/estimates-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/estimates-light.png b/apps/web/app/assets/empty-state/project-settings/estimates-light.png new file mode 100644 index 0000000..f846a2a Binary files /dev/null and b/apps/web/app/assets/empty-state/project-settings/estimates-light.png differ diff --git a/apps/web/app/assets/empty-state/project-settings/estimates-light.webp b/apps/web/app/assets/empty-state/project-settings/estimates-light.webp new file mode 100644 index 0000000..b329ea9 Binary files /dev/null and b/apps/web/app/assets/empty-state/project-settings/estimates-light.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/integrations-dark-resp.webp b/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/apps/web/app/assets/empty-state/project-settings/integrations-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/integrations-dark.webp b/apps/web/app/assets/empty-state/project-settings/integrations-dark.webp new file mode 100644 index 0000000..f968f8f Binary files /dev/null and b/apps/web/app/assets/empty-state/project-settings/integrations-dark.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/integrations-light-resp.webp b/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/apps/web/app/assets/empty-state/project-settings/integrations-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/integrations-light.webp b/apps/web/app/assets/empty-state/project-settings/integrations-light.webp new file mode 100644 index 0000000..94f6ffe Binary files /dev/null and b/apps/web/app/assets/empty-state/project-settings/integrations-light.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/labels-dark-resp.webp b/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/apps/web/app/assets/empty-state/project-settings/labels-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/labels-dark.webp b/apps/web/app/assets/empty-state/project-settings/labels-dark.webp new file mode 100644 index 0000000..6d41f55 Binary files /dev/null and b/apps/web/app/assets/empty-state/project-settings/labels-dark.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/labels-light-resp.webp b/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/apps/web/app/assets/empty-state/project-settings/labels-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/labels-light.webp b/apps/web/app/assets/empty-state/project-settings/labels-light.webp new file mode 100644 index 0000000..c6e7015 Binary files /dev/null and b/apps/web/app/assets/empty-state/project-settings/labels-light.webp differ diff --git a/apps/web/app/assets/empty-state/project-settings/no-projects-dark.png b/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/apps/web/app/assets/empty-state/project-settings/no-projects-dark.png differ diff --git a/apps/web/app/assets/empty-state/project-settings/no-projects-light.png b/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/apps/web/app/assets/empty-state/project-settings/no-projects-light.png differ diff --git a/apps/web/app/assets/empty-state/project-settings/updates-dark.png b/apps/web/app/assets/empty-state/project-settings/updates-dark.png new file mode 100644 index 0000000..46e2709 Binary files /dev/null and b/apps/web/app/assets/empty-state/project-settings/updates-dark.png differ diff --git a/apps/web/app/assets/empty-state/project-settings/updates-light.png b/apps/web/app/assets/empty-state/project-settings/updates-light.png new file mode 100644 index 0000000..d2e9208 Binary files /dev/null and b/apps/web/app/assets/empty-state/project-settings/updates-light.png differ diff --git a/apps/web/app/assets/empty-state/project.svg b/apps/web/app/assets/empty-state/project.svg new file mode 100644 index 0000000..b898549 --- /dev/null +++ b/apps/web/app/assets/empty-state/project.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/project/all-filters-dark.svg b/apps/web/app/assets/empty-state/project/all-filters-dark.svg new file mode 100644 index 0000000..e0d69c4 --- /dev/null +++ b/apps/web/app/assets/empty-state/project/all-filters-dark.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/project/all-filters-light.svg b/apps/web/app/assets/empty-state/project/all-filters-light.svg new file mode 100644 index 0000000..93ac4de --- /dev/null +++ b/apps/web/app/assets/empty-state/project/all-filters-light.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/project/name-filter-dark.svg b/apps/web/app/assets/empty-state/project/name-filter-dark.svg new file mode 100644 index 0000000..df4c237 --- /dev/null +++ b/apps/web/app/assets/empty-state/project/name-filter-dark.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/project/name-filter-light.svg b/apps/web/app/assets/empty-state/project/name-filter-light.svg new file mode 100644 index 0000000..ec63228 --- /dev/null +++ b/apps/web/app/assets/empty-state/project/name-filter-light.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/project/name-filter.svg b/apps/web/app/assets/empty-state/project/name-filter.svg new file mode 100644 index 0000000..ec63228 --- /dev/null +++ b/apps/web/app/assets/empty-state/project/name-filter.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/recent_activity.svg b/apps/web/app/assets/empty-state/recent_activity.svg new file mode 100644 index 0000000..bef318f --- /dev/null +++ b/apps/web/app/assets/empty-state/recent_activity.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/app/assets/empty-state/search/all-issue-view-dark.webp b/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/apps/web/app/assets/empty-state/search/all-issue-view-dark.webp differ diff --git a/apps/web/app/assets/empty-state/search/all-issues-view-light.webp b/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/apps/web/app/assets/empty-state/search/all-issues-view-light.webp differ diff --git a/apps/web/app/assets/empty-state/search/archive-dark.webp b/apps/web/app/assets/empty-state/search/archive-dark.webp new file mode 100644 index 0000000..d586be8 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/archive-dark.webp differ diff --git a/apps/web/app/assets/empty-state/search/archive-light.webp b/apps/web/app/assets/empty-state/search/archive-light.webp new file mode 100644 index 0000000..5a79179 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/archive-light.webp differ diff --git a/apps/web/app/assets/empty-state/search/comments-dark.webp b/apps/web/app/assets/empty-state/search/comments-dark.webp new file mode 100644 index 0000000..d06ba7e Binary files /dev/null and b/apps/web/app/assets/empty-state/search/comments-dark.webp differ diff --git a/apps/web/app/assets/empty-state/search/comments-light.webp b/apps/web/app/assets/empty-state/search/comments-light.webp new file mode 100644 index 0000000..5a66067 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/comments-light.webp differ diff --git a/apps/web/app/assets/empty-state/search/issues-dark.webp b/apps/web/app/assets/empty-state/search/issues-dark.webp new file mode 100644 index 0000000..dcd00aa Binary files /dev/null and b/apps/web/app/assets/empty-state/search/issues-dark.webp differ diff --git a/apps/web/app/assets/empty-state/search/issues-light.webp b/apps/web/app/assets/empty-state/search/issues-light.webp new file mode 100644 index 0000000..7703fde Binary files /dev/null and b/apps/web/app/assets/empty-state/search/issues-light.webp differ diff --git a/apps/web/app/assets/empty-state/search/member-dark.webp b/apps/web/app/assets/empty-state/search/member-dark.webp new file mode 100644 index 0000000..20521a5 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/member-dark.webp differ diff --git a/apps/web/app/assets/empty-state/search/member-light.webp b/apps/web/app/assets/empty-state/search/member-light.webp new file mode 100644 index 0000000..dcd38c9 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/member-light.webp differ diff --git a/apps/web/app/assets/empty-state/search/notification-dark.webp b/apps/web/app/assets/empty-state/search/notification-dark.webp new file mode 100644 index 0000000..cb299d1 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/notification-dark.webp differ diff --git a/apps/web/app/assets/empty-state/search/notification-light.webp b/apps/web/app/assets/empty-state/search/notification-light.webp new file mode 100644 index 0000000..55c4ffa Binary files /dev/null and b/apps/web/app/assets/empty-state/search/notification-light.webp differ diff --git a/apps/web/app/assets/empty-state/search/project-dark.webp b/apps/web/app/assets/empty-state/search/project-dark.webp new file mode 100644 index 0000000..98c6572 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/project-dark.webp differ diff --git a/apps/web/app/assets/empty-state/search/project-light.webp b/apps/web/app/assets/empty-state/search/project-light.webp new file mode 100644 index 0000000..cb3e470 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/project-light.webp differ diff --git a/apps/web/app/assets/empty-state/search/search-dark.webp b/apps/web/app/assets/empty-state/search/search-dark.webp new file mode 100644 index 0000000..92abc61 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/search-dark.webp differ diff --git a/apps/web/app/assets/empty-state/search/search-light.webp b/apps/web/app/assets/empty-state/search/search-light.webp new file mode 100644 index 0000000..807ed26 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/search-light.webp differ diff --git a/apps/web/app/assets/empty-state/search/snooze-dark.webp b/apps/web/app/assets/empty-state/search/snooze-dark.webp new file mode 100644 index 0000000..296e797 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/snooze-dark.webp differ diff --git a/apps/web/app/assets/empty-state/search/snooze-light.webp b/apps/web/app/assets/empty-state/search/snooze-light.webp new file mode 100644 index 0000000..897ff35 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/snooze-light.webp differ diff --git a/apps/web/app/assets/empty-state/search/views-dark.webp b/apps/web/app/assets/empty-state/search/views-dark.webp new file mode 100644 index 0000000..96da5e3 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/views-dark.webp differ diff --git a/apps/web/app/assets/empty-state/search/views-light.webp b/apps/web/app/assets/empty-state/search/views-light.webp new file mode 100644 index 0000000..3cd1d23 Binary files /dev/null and b/apps/web/app/assets/empty-state/search/views-light.webp differ diff --git a/apps/web/app/assets/empty-state/state_graph.svg b/apps/web/app/assets/empty-state/state_graph.svg new file mode 100644 index 0000000..651ea34 --- /dev/null +++ b/apps/web/app/assets/empty-state/state_graph.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/stickies/stickies-dark.webp b/apps/web/app/assets/empty-state/stickies/stickies-dark.webp new file mode 100644 index 0000000..a93e44b Binary files /dev/null and b/apps/web/app/assets/empty-state/stickies/stickies-dark.webp differ diff --git a/apps/web/app/assets/empty-state/stickies/stickies-light.webp b/apps/web/app/assets/empty-state/stickies/stickies-light.webp new file mode 100644 index 0000000..c7fd1f1 Binary files /dev/null and b/apps/web/app/assets/empty-state/stickies/stickies-light.webp differ diff --git a/apps/web/app/assets/empty-state/stickies/stickies-search-dark.webp b/apps/web/app/assets/empty-state/stickies/stickies-search-dark.webp new file mode 100644 index 0000000..6afdc0c Binary files /dev/null and b/apps/web/app/assets/empty-state/stickies/stickies-search-dark.webp differ diff --git a/apps/web/app/assets/empty-state/stickies/stickies-search-light.webp b/apps/web/app/assets/empty-state/stickies/stickies-search-light.webp new file mode 100644 index 0000000..2b8ca8c Binary files /dev/null and b/apps/web/app/assets/empty-state/stickies/stickies-search-light.webp differ diff --git a/apps/web/app/assets/empty-state/view.svg b/apps/web/app/assets/empty-state/view.svg new file mode 100644 index 0000000..478cf6e --- /dev/null +++ b/apps/web/app/assets/empty-state/view.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/web-hook.svg b/apps/web/app/assets/empty-state/web-hook.svg new file mode 100644 index 0000000..f8e32d3 --- /dev/null +++ b/apps/web/app/assets/empty-state/web-hook.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/wiki/all-dark.webp b/apps/web/app/assets/empty-state/wiki/all-dark.webp new file mode 100644 index 0000000..81ef36d Binary files /dev/null and b/apps/web/app/assets/empty-state/wiki/all-dark.webp differ diff --git a/apps/web/app/assets/empty-state/wiki/all-filters-dark.svg b/apps/web/app/assets/empty-state/wiki/all-filters-dark.svg new file mode 100644 index 0000000..037a2fd --- /dev/null +++ b/apps/web/app/assets/empty-state/wiki/all-filters-dark.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/wiki/all-filters-light.svg b/apps/web/app/assets/empty-state/wiki/all-filters-light.svg new file mode 100644 index 0000000..8ecd8d2 --- /dev/null +++ b/apps/web/app/assets/empty-state/wiki/all-filters-light.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/wiki/all-light.webp b/apps/web/app/assets/empty-state/wiki/all-light.webp new file mode 100644 index 0000000..02b1d88 Binary files /dev/null and b/apps/web/app/assets/empty-state/wiki/all-light.webp differ diff --git a/apps/web/app/assets/empty-state/wiki/archived-dark.webp b/apps/web/app/assets/empty-state/wiki/archived-dark.webp new file mode 100644 index 0000000..64700c8 Binary files /dev/null and b/apps/web/app/assets/empty-state/wiki/archived-dark.webp differ diff --git a/apps/web/app/assets/empty-state/wiki/archived-light.webp b/apps/web/app/assets/empty-state/wiki/archived-light.webp new file mode 100644 index 0000000..9412f73 Binary files /dev/null and b/apps/web/app/assets/empty-state/wiki/archived-light.webp differ diff --git a/apps/web/app/assets/empty-state/wiki/name-filter-dark.svg b/apps/web/app/assets/empty-state/wiki/name-filter-dark.svg new file mode 100644 index 0000000..969a1fe --- /dev/null +++ b/apps/web/app/assets/empty-state/wiki/name-filter-dark.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/wiki/name-filter-light.svg b/apps/web/app/assets/empty-state/wiki/name-filter-light.svg new file mode 100644 index 0000000..bc2465a --- /dev/null +++ b/apps/web/app/assets/empty-state/wiki/name-filter-light.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/empty-state/wiki/navigation-pane/assets-dark.webp b/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/apps/web/app/assets/empty-state/wiki/navigation-pane/assets-dark.webp differ diff --git a/apps/web/app/assets/empty-state/wiki/navigation-pane/assets-light.webp b/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/apps/web/app/assets/empty-state/wiki/navigation-pane/assets-light.webp differ diff --git a/apps/web/app/assets/empty-state/wiki/navigation-pane/outline-dark.webp b/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/apps/web/app/assets/empty-state/wiki/navigation-pane/outline-dark.webp differ diff --git a/apps/web/app/assets/empty-state/wiki/navigation-pane/outline-light.webp b/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/apps/web/app/assets/empty-state/wiki/navigation-pane/outline-light.webp differ diff --git a/apps/web/app/assets/empty-state/wiki/private-dark.webp b/apps/web/app/assets/empty-state/wiki/private-dark.webp new file mode 100644 index 0000000..b482f43 Binary files /dev/null and b/apps/web/app/assets/empty-state/wiki/private-dark.webp differ diff --git a/apps/web/app/assets/empty-state/wiki/private-light.webp b/apps/web/app/assets/empty-state/wiki/private-light.webp new file mode 100644 index 0000000..e1c3af9 Binary files /dev/null and b/apps/web/app/assets/empty-state/wiki/private-light.webp differ diff --git a/apps/web/app/assets/empty-state/wiki/public-dark.webp b/apps/web/app/assets/empty-state/wiki/public-dark.webp new file mode 100644 index 0000000..571bf8a Binary files /dev/null and b/apps/web/app/assets/empty-state/wiki/public-dark.webp differ diff --git a/apps/web/app/assets/empty-state/wiki/public-light.webp b/apps/web/app/assets/empty-state/wiki/public-light.webp new file mode 100644 index 0000000..e0f7b7d Binary files /dev/null and b/apps/web/app/assets/empty-state/wiki/public-light.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-draft/issue-dark.webp b/apps/web/app/assets/empty-state/workspace-draft/issue-dark.webp new file mode 100644 index 0000000..2765335 Binary files /dev/null and b/apps/web/app/assets/empty-state/workspace-draft/issue-dark.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-draft/issue-light.webp b/apps/web/app/assets/empty-state/workspace-draft/issue-light.webp new file mode 100644 index 0000000..8be274b Binary files /dev/null and b/apps/web/app/assets/empty-state/workspace-draft/issue-light.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark-resp.webp b/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/apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark.webp b/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/apps/web/app/assets/empty-state/workspace-settings/api-tokens-dark.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/api-tokens-light-resp.webp b/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/apps/web/app/assets/empty-state/workspace-settings/api-tokens-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/api-tokens-light.webp b/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/apps/web/app/assets/empty-state/workspace-settings/api-tokens-light.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/exports-dark-resp.webp b/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/apps/web/app/assets/empty-state/workspace-settings/exports-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/exports-dark.webp b/apps/web/app/assets/empty-state/workspace-settings/exports-dark.webp new file mode 100644 index 0000000..b0911c9 Binary files /dev/null and b/apps/web/app/assets/empty-state/workspace-settings/exports-dark.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/exports-light-resp.webp b/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/apps/web/app/assets/empty-state/workspace-settings/exports-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/exports-light.webp b/apps/web/app/assets/empty-state/workspace-settings/exports-light.webp new file mode 100644 index 0000000..5e08f21 Binary files /dev/null and b/apps/web/app/assets/empty-state/workspace-settings/exports-light.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/imports-dark-resp.webp b/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/apps/web/app/assets/empty-state/workspace-settings/imports-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/imports-dark.webp b/apps/web/app/assets/empty-state/workspace-settings/imports-dark.webp new file mode 100644 index 0000000..428ee97 Binary files /dev/null and b/apps/web/app/assets/empty-state/workspace-settings/imports-dark.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/imports-light-resp.webp b/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/apps/web/app/assets/empty-state/workspace-settings/imports-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/imports-light.webp b/apps/web/app/assets/empty-state/workspace-settings/imports-light.webp new file mode 100644 index 0000000..5f42180 Binary files /dev/null and b/apps/web/app/assets/empty-state/workspace-settings/imports-light.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/integrations-dark-resp.webp b/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/apps/web/app/assets/empty-state/workspace-settings/integrations-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/integrations-dark.webp b/apps/web/app/assets/empty-state/workspace-settings/integrations-dark.webp new file mode 100644 index 0000000..603a658 Binary files /dev/null and b/apps/web/app/assets/empty-state/workspace-settings/integrations-dark.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/integrations-light-resp.webp b/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/apps/web/app/assets/empty-state/workspace-settings/integrations-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/integrations-light.webp b/apps/web/app/assets/empty-state/workspace-settings/integrations-light.webp new file mode 100644 index 0000000..eb62503 Binary files /dev/null and b/apps/web/app/assets/empty-state/workspace-settings/integrations-light.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark-resp.webp b/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/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark-resp.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark.webp b/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark.webp new file mode 100644 index 0000000..88a584e Binary files /dev/null and b/apps/web/app/assets/empty-state/workspace-settings/webhooks-dark.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/webhooks-light-resp.webp b/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/apps/web/app/assets/empty-state/workspace-settings/webhooks-light-resp.webp differ diff --git a/apps/web/app/assets/empty-state/workspace-settings/webhooks-light.webp b/apps/web/app/assets/empty-state/workspace-settings/webhooks-light.webp new file mode 100644 index 0000000..6c76b5a Binary files /dev/null and b/apps/web/app/assets/empty-state/workspace-settings/webhooks-light.webp differ diff --git a/apps/web/app/assets/favicon/apple-touch-icon.png b/apps/web/app/assets/favicon/apple-touch-icon.png new file mode 100644 index 0000000..a631267 Binary files /dev/null and b/apps/web/app/assets/favicon/apple-touch-icon.png differ diff --git a/apps/web/app/assets/favicon/favicon-16x16.png b/apps/web/app/assets/favicon/favicon-16x16.png new file mode 100644 index 0000000..af59ef0 Binary files /dev/null and b/apps/web/app/assets/favicon/favicon-16x16.png differ diff --git a/apps/web/app/assets/favicon/favicon-32x32.png b/apps/web/app/assets/favicon/favicon-32x32.png new file mode 100644 index 0000000..16a1271 Binary files /dev/null and b/apps/web/app/assets/favicon/favicon-32x32.png differ diff --git a/apps/web/app/assets/favicon/favicon.ico b/apps/web/app/assets/favicon/favicon.ico new file mode 100644 index 0000000..613b1a3 Binary files /dev/null and b/apps/web/app/assets/favicon/favicon.ico differ diff --git a/apps/web/app/assets/fonts/inter/bold-italic.ttf b/apps/web/app/assets/fonts/inter/bold-italic.ttf new file mode 100644 index 0000000..0d19c1a Binary files /dev/null and b/apps/web/app/assets/fonts/inter/bold-italic.ttf differ diff --git a/apps/web/app/assets/fonts/inter/bold.ttf b/apps/web/app/assets/fonts/inter/bold.ttf new file mode 100644 index 0000000..cd13f60 Binary files /dev/null and b/apps/web/app/assets/fonts/inter/bold.ttf differ diff --git a/apps/web/app/assets/fonts/inter/heavy-italic.ttf b/apps/web/app/assets/fonts/inter/heavy-italic.ttf new file mode 100644 index 0000000..b33602f Binary files /dev/null and b/apps/web/app/assets/fonts/inter/heavy-italic.ttf differ diff --git a/apps/web/app/assets/fonts/inter/heavy.ttf b/apps/web/app/assets/fonts/inter/heavy.ttf new file mode 100644 index 0000000..89673de Binary files /dev/null and b/apps/web/app/assets/fonts/inter/heavy.ttf differ diff --git a/apps/web/app/assets/fonts/inter/light-italic.ttf b/apps/web/app/assets/fonts/inter/light-italic.ttf new file mode 100644 index 0000000..f69e18b Binary files /dev/null and b/apps/web/app/assets/fonts/inter/light-italic.ttf differ diff --git a/apps/web/app/assets/fonts/inter/light.ttf b/apps/web/app/assets/fonts/inter/light.ttf new file mode 100644 index 0000000..acae361 Binary files /dev/null and b/apps/web/app/assets/fonts/inter/light.ttf differ diff --git a/apps/web/app/assets/fonts/inter/medium-italic.ttf b/apps/web/app/assets/fonts/inter/medium-italic.ttf new file mode 100644 index 0000000..5c8c8b1 Binary files /dev/null and b/apps/web/app/assets/fonts/inter/medium-italic.ttf differ diff --git a/apps/web/app/assets/fonts/inter/medium.ttf b/apps/web/app/assets/fonts/inter/medium.ttf new file mode 100644 index 0000000..71d9017 Binary files /dev/null and b/apps/web/app/assets/fonts/inter/medium.ttf differ diff --git a/apps/web/app/assets/fonts/inter/regular-italic.ttf b/apps/web/app/assets/fonts/inter/regular-italic.ttf new file mode 100644 index 0000000..14d3595 Binary files /dev/null and b/apps/web/app/assets/fonts/inter/regular-italic.ttf differ diff --git a/apps/web/app/assets/fonts/inter/regular.ttf b/apps/web/app/assets/fonts/inter/regular.ttf new file mode 100644 index 0000000..ce097c8 Binary files /dev/null and b/apps/web/app/assets/fonts/inter/regular.ttf differ diff --git a/apps/web/app/assets/fonts/inter/semibold-italic.ttf b/apps/web/app/assets/fonts/inter/semibold-italic.ttf new file mode 100644 index 0000000..d9c9896 Binary files /dev/null and b/apps/web/app/assets/fonts/inter/semibold-italic.ttf differ diff --git a/apps/web/app/assets/fonts/inter/semibold.ttf b/apps/web/app/assets/fonts/inter/semibold.ttf new file mode 100644 index 0000000..053185e Binary files /dev/null and b/apps/web/app/assets/fonts/inter/semibold.ttf differ diff --git a/apps/web/app/assets/fonts/inter/thin-italic.ttf b/apps/web/app/assets/fonts/inter/thin-italic.ttf new file mode 100644 index 0000000..134e837 Binary files /dev/null and b/apps/web/app/assets/fonts/inter/thin-italic.ttf differ diff --git a/apps/web/app/assets/fonts/inter/thin.ttf b/apps/web/app/assets/fonts/inter/thin.ttf new file mode 100644 index 0000000..e68ec47 Binary files /dev/null and b/apps/web/app/assets/fonts/inter/thin.ttf differ diff --git a/apps/web/app/assets/fonts/inter/ultrabold-italic.ttf b/apps/web/app/assets/fonts/inter/ultrabold-italic.ttf new file mode 100644 index 0000000..df45062 Binary files /dev/null and b/apps/web/app/assets/fonts/inter/ultrabold-italic.ttf differ diff --git a/apps/web/app/assets/fonts/inter/ultrabold.ttf b/apps/web/app/assets/fonts/inter/ultrabold.ttf new file mode 100644 index 0000000..e71c601 Binary files /dev/null and b/apps/web/app/assets/fonts/inter/ultrabold.ttf differ diff --git a/apps/web/app/assets/fonts/inter/ultralight-italic.ttf b/apps/web/app/assets/fonts/inter/ultralight-italic.ttf new file mode 100644 index 0000000..275f305 Binary files /dev/null and b/apps/web/app/assets/fonts/inter/ultralight-italic.ttf differ diff --git a/apps/web/app/assets/fonts/inter/ultralight.ttf b/apps/web/app/assets/fonts/inter/ultralight.ttf new file mode 100644 index 0000000..f9c6cfc Binary files /dev/null and b/apps/web/app/assets/fonts/inter/ultralight.ttf differ diff --git a/apps/web/app/assets/icons/icon-180x180.png b/apps/web/app/assets/icons/icon-180x180.png new file mode 100644 index 0000000..e7142bc Binary files /dev/null and b/apps/web/app/assets/icons/icon-180x180.png differ diff --git a/apps/web/app/assets/icons/icon-512x512.png b/apps/web/app/assets/icons/icon-512x512.png new file mode 100644 index 0000000..4c070d0 Binary files /dev/null and b/apps/web/app/assets/icons/icon-512x512.png differ diff --git a/apps/web/app/assets/images/logo-spinner-dark.gif b/apps/web/app/assets/images/logo-spinner-dark.gif new file mode 100644 index 0000000..8bd0832 Binary files /dev/null and b/apps/web/app/assets/images/logo-spinner-dark.gif differ diff --git a/apps/web/app/assets/images/logo-spinner-light.gif b/apps/web/app/assets/images/logo-spinner-light.gif new file mode 100644 index 0000000..8b57103 Binary files /dev/null and b/apps/web/app/assets/images/logo-spinner-light.gif differ diff --git a/apps/web/app/assets/instance-not-ready.webp b/apps/web/app/assets/instance-not-ready.webp new file mode 100644 index 0000000..ceabbcc Binary files /dev/null and b/apps/web/app/assets/instance-not-ready.webp differ diff --git a/apps/web/app/assets/instance-setup-done.webp b/apps/web/app/assets/instance-setup-done.webp new file mode 100644 index 0000000..4773ed7 Binary files /dev/null and b/apps/web/app/assets/instance-setup-done.webp differ diff --git a/apps/web/app/assets/instance/maintenance-mode-dark.svg b/apps/web/app/assets/instance/maintenance-mode-dark.svg new file mode 100644 index 0000000..546125f --- /dev/null +++ b/apps/web/app/assets/instance/maintenance-mode-dark.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/instance/maintenance-mode-light.svg b/apps/web/app/assets/instance/maintenance-mode-light.svg new file mode 100644 index 0000000..6cd8bab --- /dev/null +++ b/apps/web/app/assets/instance/maintenance-mode-light.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/logos/gitea-logo.svg b/apps/web/app/assets/logos/gitea-logo.svg new file mode 100644 index 0000000..4329134 --- /dev/null +++ b/apps/web/app/assets/logos/gitea-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/app/assets/logos/github-black.png b/apps/web/app/assets/logos/github-black.png new file mode 100644 index 0000000..7a7a824 Binary files /dev/null and b/apps/web/app/assets/logos/github-black.png differ diff --git a/apps/web/app/assets/logos/github-dark.svg b/apps/web/app/assets/logos/github-dark.svg new file mode 100644 index 0000000..a0cb35c --- /dev/null +++ b/apps/web/app/assets/logos/github-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/app/assets/logos/github-square.png b/apps/web/app/assets/logos/github-square.png new file mode 100644 index 0000000..527ba79 Binary files /dev/null and b/apps/web/app/assets/logos/github-square.png differ diff --git a/apps/web/app/assets/logos/github-white.png b/apps/web/app/assets/logos/github-white.png new file mode 100644 index 0000000..dbb2b57 Binary files /dev/null and b/apps/web/app/assets/logos/github-white.png differ diff --git a/apps/web/app/assets/logos/gitlab-logo.svg b/apps/web/app/assets/logos/gitlab-logo.svg new file mode 100644 index 0000000..dab4d8b --- /dev/null +++ b/apps/web/app/assets/logos/gitlab-logo.svg @@ -0,0 +1 @@ + diff --git a/apps/web/app/assets/logos/google-logo.svg b/apps/web/app/assets/logos/google-logo.svg new file mode 100644 index 0000000..088288f --- /dev/null +++ b/apps/web/app/assets/logos/google-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/app/assets/mac-command.svg b/apps/web/app/assets/mac-command.svg new file mode 100644 index 0000000..5cee459 --- /dev/null +++ b/apps/web/app/assets/mac-command.svg @@ -0,0 +1,2 @@ + +mac-command \ No newline at end of file diff --git a/apps/web/app/assets/og-image.png b/apps/web/app/assets/og-image.png new file mode 100644 index 0000000..dbf89c6 Binary files /dev/null and b/apps/web/app/assets/og-image.png differ diff --git a/apps/web/app/assets/onboarding/cycles.webp b/apps/web/app/assets/onboarding/cycles.webp new file mode 100644 index 0000000..278803b Binary files /dev/null and b/apps/web/app/assets/onboarding/cycles.webp differ diff --git a/apps/web/app/assets/onboarding/issues.webp b/apps/web/app/assets/onboarding/issues.webp new file mode 100644 index 0000000..5aed9e3 Binary files /dev/null and b/apps/web/app/assets/onboarding/issues.webp differ diff --git a/apps/web/app/assets/onboarding/modules.webp b/apps/web/app/assets/onboarding/modules.webp new file mode 100644 index 0000000..974e464 Binary files /dev/null and b/apps/web/app/assets/onboarding/modules.webp differ diff --git a/apps/web/app/assets/onboarding/onboarding-pages.webp b/apps/web/app/assets/onboarding/onboarding-pages.webp new file mode 100644 index 0000000..536f9ec Binary files /dev/null and b/apps/web/app/assets/onboarding/onboarding-pages.webp differ diff --git a/apps/web/app/assets/onboarding/pages.webp b/apps/web/app/assets/onboarding/pages.webp new file mode 100644 index 0000000..27004d6 Binary files /dev/null and b/apps/web/app/assets/onboarding/pages.webp differ diff --git a/apps/web/app/assets/onboarding/views.webp b/apps/web/app/assets/onboarding/views.webp new file mode 100644 index 0000000..9c958ec Binary files /dev/null and b/apps/web/app/assets/onboarding/views.webp differ diff --git a/apps/web/app/assets/plane-logos/black-horizontal-with-blue-logo.png b/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/apps/web/app/assets/plane-logos/black-horizontal-with-blue-logo.png differ diff --git a/apps/web/app/assets/plane-logos/blue-without-text.png b/apps/web/app/assets/plane-logos/blue-without-text.png new file mode 100644 index 0000000..ea94aec Binary files /dev/null and b/apps/web/app/assets/plane-logos/blue-without-text.png differ diff --git a/apps/web/app/assets/plane-logos/white-horizontal-with-blue-logo.png b/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/apps/web/app/assets/plane-logos/white-horizontal-with-blue-logo.png differ diff --git a/apps/web/app/assets/plane-logos/white-horizontal.svg b/apps/web/app/assets/plane-logos/white-horizontal.svg new file mode 100644 index 0000000..13e2dbb --- /dev/null +++ b/apps/web/app/assets/plane-logos/white-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/plane-takeoff.png b/apps/web/app/assets/plane-takeoff.png new file mode 100644 index 0000000..417ff82 Binary files /dev/null and b/apps/web/app/assets/plane-takeoff.png differ diff --git a/apps/web/app/assets/scribble/scribble-black.svg b/apps/web/app/assets/scribble/scribble-black.svg new file mode 100644 index 0000000..556b956 --- /dev/null +++ b/apps/web/app/assets/scribble/scribble-black.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/web/app/assets/scribble/scribble-white.svg b/apps/web/app/assets/scribble/scribble-white.svg new file mode 100644 index 0000000..2133897 --- /dev/null +++ b/apps/web/app/assets/scribble/scribble-white.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/web/app/assets/services/csv.svg b/apps/web/app/assets/services/csv.svg new file mode 100644 index 0000000..adea1dd --- /dev/null +++ b/apps/web/app/assets/services/csv.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/app/assets/services/excel.svg b/apps/web/app/assets/services/excel.svg new file mode 100644 index 0000000..86fb8e6 --- /dev/null +++ b/apps/web/app/assets/services/excel.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/web/app/assets/services/github.png b/apps/web/app/assets/services/github.png new file mode 100644 index 0000000..527ba79 Binary files /dev/null and b/apps/web/app/assets/services/github.png differ diff --git a/apps/web/app/assets/services/jira.svg b/apps/web/app/assets/services/jira.svg new file mode 100644 index 0000000..5e5cebc --- /dev/null +++ b/apps/web/app/assets/services/jira.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/web/app/assets/services/json.svg b/apps/web/app/assets/services/json.svg new file mode 100644 index 0000000..0fe32e2 --- /dev/null +++ b/apps/web/app/assets/services/json.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/app/assets/services/slack.png b/apps/web/app/assets/services/slack.png new file mode 100644 index 0000000..fc91800 Binary files /dev/null and b/apps/web/app/assets/services/slack.png differ diff --git a/apps/web/app/assets/user.png b/apps/web/app/assets/user.png new file mode 100644 index 0000000..bc02512 Binary files /dev/null and b/apps/web/app/assets/user.png differ diff --git a/apps/web/app/assets/users/user-1.png b/apps/web/app/assets/users/user-1.png new file mode 100644 index 0000000..b69f539 Binary files /dev/null and b/apps/web/app/assets/users/user-1.png differ diff --git a/apps/web/app/assets/users/user-2.png b/apps/web/app/assets/users/user-2.png new file mode 100644 index 0000000..c5b9bc5 Binary files /dev/null and b/apps/web/app/assets/users/user-2.png differ diff --git a/apps/web/app/assets/users/user-profile-cover-default-img.png b/apps/web/app/assets/users/user-profile-cover-default-img.png new file mode 100644 index 0000000..6f555da Binary files /dev/null and b/apps/web/app/assets/users/user-profile-cover-default-img.png differ diff --git a/apps/web/app/assets/workspace-active-cycles/cta-l-1-dark.webp b/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/apps/web/app/assets/workspace-active-cycles/cta-l-1-dark.webp differ diff --git a/apps/web/app/assets/workspace-active-cycles/cta-l-1-light.webp b/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/apps/web/app/assets/workspace-active-cycles/cta-l-1-light.webp differ diff --git a/apps/web/app/assets/workspace-active-cycles/cta-r-1-dark.webp b/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/apps/web/app/assets/workspace-active-cycles/cta-r-1-dark.webp differ diff --git a/apps/web/app/assets/workspace-active-cycles/cta-r-1-light.webp b/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/apps/web/app/assets/workspace-active-cycles/cta-r-1-light.webp differ diff --git a/apps/web/app/assets/workspace-active-cycles/cta-r-2-dark.webp b/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/apps/web/app/assets/workspace-active-cycles/cta-r-2-dark.webp differ diff --git a/apps/web/app/assets/workspace-active-cycles/cta-r-2-light.webp b/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/apps/web/app/assets/workspace-active-cycles/cta-r-2-light.webp differ diff --git a/apps/web/app/assets/workspace/workspace-creation-disabled.png b/apps/web/app/assets/workspace/workspace-creation-disabled.png new file mode 100644 index 0000000..09e05ef Binary files /dev/null and b/apps/web/app/assets/workspace/workspace-creation-disabled.png differ diff --git a/apps/web/app/assets/workspace/workspace-not-available.png b/apps/web/app/assets/workspace/workspace-not-available.png new file mode 100644 index 0000000..e95cdf0 Binary files /dev/null and b/apps/web/app/assets/workspace/workspace-not-available.png differ diff --git a/apps/web/app/compat/next/helper.ts b/apps/web/app/compat/next/helper.ts new file mode 100644 index 0000000..c4edf3d --- /dev/null +++ b/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/apps/web/app/compat/next/image.tsx b/apps/web/app/compat/next/image.tsx new file mode 100644 index 0000000..f229d0d --- /dev/null +++ b/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/apps/web/app/compat/next/link.tsx b/apps/web/app/compat/next/link.tsx new file mode 100644 index 0000000..8517756 --- /dev/null +++ b/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/apps/web/app/compat/next/navigation.ts b/apps/web/app/compat/next/navigation.ts new file mode 100644 index 0000000..3803ffe --- /dev/null +++ b/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/apps/web/app/compat/next/script.tsx b/apps/web/app/compat/next/script.tsx new file mode 100644 index 0000000..c2b470b --- /dev/null +++ b/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/apps/web/app/entry.client.tsx b/apps/web/app/entry.client.tsx new file mode 100644 index 0000000..071abc8 --- /dev/null +++ b/apps/web/app/entry.client.tsx @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { initPromise } from "@plane/i18n"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { HydratedRouter } from "react-router/dom"; + +import polyfills from "@/lib/polyfills"; +import { isStaleAssetErrorMessage, recoverFromStaleAsset } from "@/lib/stale-asset-error"; + +void polyfills; + +// Production-only: in dev these errors come from the dev server itself (restarts, +// stale optimized deps) and auto-reloading would mask them. +if (import.meta.env.PROD) { + window.addEventListener("vite:preloadError", (event) => { + if (recoverFromStaleAsset()) event.preventDefault(); + }); + + window.addEventListener("error", (event) => { + if (isStaleAssetErrorMessage(event.message || "")) recoverFromStaleAsset(); + }); + + window.addEventListener("unhandledrejection", (event) => { + const reason = event.reason instanceof Error ? event.reason.message : String(event.reason ?? ""); + if (isStaleAssetErrorMessage(reason)) recoverFromStaleAsset(); + }); +} + +// Initialize i18n before hydrating (the remix-i18next pattern for React +// Router: await init, then hydrateRoot). Hydrating before the instance is +// ready would make the first client render diverge from the prerendered +// shell, and React 19 leaves DOM it could not adopt in place instead of +// clearing it. +void initPromise + .catch(() => {}) + .then(() => { + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); + }); diff --git a/apps/web/app/error/dev.tsx b/apps/web/app/error/dev.tsx new file mode 100644 index 0000000..b9b1d48 --- /dev/null +++ b/apps/web/app/error/dev.tsx @@ -0,0 +1,146 @@ +/** + * 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 "@makeplane/propel/components/banner"; +import { InfoFilled } from "@makeplane/propel/icons"; +import { Button } from "@plane/propel/button"; +import { Card, ECardVariant } from "@plane/propel/card"; + +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" /> + + +
+
+

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

+
+
+ +
+

Error Data

+
+

{error.data}

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

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" /> + + +
+
+

Unknown Error

+
+
+ +
+

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

+
+ + +
+ +
+
+ ); +} diff --git a/apps/web/app/error/index.tsx b/apps/web/app/error/index.tsx new file mode 100644 index 0000000..9c3712b --- /dev/null +++ b/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/apps/web/app/error/prod.tsx b/apps/web/app/error/prod.tsx new file mode 100644 index 0000000..a8d6182 --- /dev/null +++ b/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/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..165d835 --- /dev/null +++ b/apps/web/app/layout.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 { Outlet, redirect } from "react-router"; +// plane imports +import { cn } from "@plane/utils"; +// local +import { ensureTrailingSlash } from "./compat/next/helper"; +import { AppProvider } from "./provider"; +import type { Route } from "./+types/layout"; + +const trailingSlashRedirectMiddleware: Route.ClientMiddlewareFunction = async ({ request }, next) => { + const canonical = ensureTrailingSlash(request.url); + if (canonical !== request.url) { + throw redirect(canonical, { status: 308 }); + } + return next(); +}; + +export const clientMiddleware: Route.ClientMiddlewareFunction[] = [trailingSlashRedirectMiddleware]; + +// Pathless layout route wrapping every route (see app/routes.ts). Providers, the store +// layer, and app chrome live here instead of root.tsx so they stay out of the SPA-mode +// server build — see the note in app/root.tsx. +export default function AppShellLayout() { + return ( + +
+
+ +
+
+
+ ); +} diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx new file mode 100644 index 0000000..1376395 --- /dev/null +++ b/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/apps/web/app/provider.tsx b/apps/web/app/provider.tsx new file mode 100644 index 0000000..016b66c --- /dev/null +++ b/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/apps/web/app/root.tsx b/apps/web/app/root.tsx new file mode 100644 index 0000000..8034624 --- /dev/null +++ b/apps/web/app/root.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 type { ReactNode } from "react"; +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"; +// 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"; +// lib +import { isStaleAssetError, recoverFromStaleAsset } from "@/lib/stale-asset-error"; +// local +import { CustomErrorComponent } from "./error"; +// 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 | Simple, extensible, open-source project management tool."; + +export const links: LinksFunction = () => [ + { rel: "icon", type: "image/png", sizes: "32x32", href: favicon32 }, + { rel: "icon", type: "image/png", sizes: "16x16", href: favicon16 }, + { rel: "shortcut icon", href: faviconIco }, + { rel: "manifest", href: "/site.webmanifest.json" }, + { rel: "apple-touch-icon", href: icon512 }, + { rel: "apple-touch-icon", sizes: "180x180", href: icon180 }, + { rel: "apple-touch-icon", sizes: "512x512", href: icon512 }, + { rel: "manifest", href: "/manifest.json" }, + { rel: "stylesheet", href: globalStyles }, + { + rel: "preload", + href: interVariableWoff2, + as: "font", + type: "font/woff2", + crossOrigin: "anonymous", + }, +]; + +export function Layout({ children }: { children: ReactNode }) { + return ( + + + + + + {/* Meta info for PWA */} + + + + + + + + + + +
+
+ + {children} + + + + + ); +} + +export const meta: Route.MetaFunction = () => [ + { title: APP_TITLE }, + { name: "description", content: SITE_DESCRIPTION }, + { property: "og:title", content: APP_TITLE }, + { + property: "og:description", + content: "Open-source project management tool to manage work items, cycles, and product roadmaps easily", + }, + { property: "og:url", content: "https://app.plane.so/" }, + { property: "og:image", content: ogImage }, + { property: "og:image:width", content: "1200" }, + { property: "og:image:height", content: "630" }, + { property: "og:image:alt", content: "Plane - Modern project management" }, + { + name: "keywords", + content: + "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", + }, + { name: "twitter:site", content: "@planepowers" }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:image", content: ogImage }, + { name: "twitter:image:width", content: "1200" }, + { name: "twitter:image:height", content: "630" }, + { name: "twitter:image:alt", content: "Plane - Modern project management" }, +]; + +// Root stays shell-thin: in SPA mode React Router server-builds only the root route, so +// everything imported here is evaluated in Node just to prerender the fallback index.html. +// Providers, the store layer, and app chrome belong in app/layout.tsx — never import them here. +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) { + // A stale chunk failure surfaces here as React Router's own wrapper error + // (the failed dynamic import itself never reaches a window event) — recover + // the same way entry.client.tsx does instead of just showing the error page. + if (import.meta.env.PROD && isStaleAssetError(error)) recoverFromStaleAsset(); + + return ; +} diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts new file mode 100644 index 0000000..0283958 --- /dev/null +++ b/apps/web/app/routes.ts @@ -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 { layout, 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. + * + * Every route nests under the pathless app/layout.tsx shell; root.tsx stays + * shell-thin (see the note in app/root.tsx). + */ +const mergedRoutes: RouteConfigEntry[] = mergeRoutes(coreRoutes, extendedRoutes); + +// Add catch-all route at the end (404 handler) +const routes: RouteConfigEntry[] = [layout("./layout.tsx", [...mergedRoutes, route("*", "./not-found.tsx")])]; + +export default routes; diff --git a/apps/web/app/routes/core.ts b/apps/web/app/routes/core.ts new file mode 100644 index 0000000..c9c82bd --- /dev/null +++ b/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/apps/web/app/routes/extended.ts b/apps/web/app/routes/extended.ts new file mode 100644 index 0000000..452d214 --- /dev/null +++ b/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/apps/web/app/routes/helper.ts b/apps/web/app/routes/helper.ts new file mode 100644 index 0000000..74dcab5 --- /dev/null +++ b/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/apps/web/app/routes/redirects/core/accounts-signup.tsx b/apps/web/app/routes/redirects/core/accounts-signup.tsx new file mode 100644 index 0000000..be1ca73 --- /dev/null +++ b/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/apps/web/app/routes/redirects/core/analytics.tsx b/apps/web/app/routes/redirects/core/analytics.tsx new file mode 100644 index 0000000..3cc7c81 --- /dev/null +++ b/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/apps/web/app/routes/redirects/core/api-tokens.tsx b/apps/web/app/routes/redirects/core/api-tokens.tsx new file mode 100644 index 0000000..9d070a1 --- /dev/null +++ b/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/apps/web/app/routes/redirects/core/inbox.tsx b/apps/web/app/routes/redirects/core/inbox.tsx new file mode 100644 index 0000000..2d71229 --- /dev/null +++ b/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/apps/web/app/routes/redirects/core/index.ts b/apps/web/app/routes/redirects/core/index.ts new file mode 100644 index 0000000..e1eaeca --- /dev/null +++ b/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/apps/web/app/routes/redirects/core/login.tsx b/apps/web/app/routes/redirects/core/login.tsx new file mode 100644 index 0000000..d591a33 --- /dev/null +++ b/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/apps/web/app/routes/redirects/core/profile-settings.tsx b/apps/web/app/routes/redirects/core/profile-settings.tsx new file mode 100644 index 0000000..82362df --- /dev/null +++ b/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/apps/web/app/routes/redirects/core/project-settings.tsx b/apps/web/app/routes/redirects/core/project-settings.tsx new file mode 100644 index 0000000..db74d57 --- /dev/null +++ b/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/apps/web/app/routes/redirects/core/register.tsx b/apps/web/app/routes/redirects/core/register.tsx new file mode 100644 index 0000000..bdcb453 --- /dev/null +++ b/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/apps/web/app/routes/redirects/core/sign-in.tsx b/apps/web/app/routes/redirects/core/sign-in.tsx new file mode 100644 index 0000000..5016e25 --- /dev/null +++ b/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/apps/web/app/routes/redirects/core/signin.tsx b/apps/web/app/routes/redirects/core/signin.tsx new file mode 100644 index 0000000..f557230 --- /dev/null +++ b/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/apps/web/app/routes/redirects/core/workspace-account-settings.tsx b/apps/web/app/routes/redirects/core/workspace-account-settings.tsx new file mode 100644 index 0000000..2e44a5b --- /dev/null +++ b/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/apps/web/app/routes/redirects/extended/index.ts b/apps/web/app/routes/redirects/extended/index.ts new file mode 100644 index 0000000..7b4da92 --- /dev/null +++ b/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/apps/web/app/routes/redirects/index.ts b/apps/web/app/routes/redirects/index.ts new file mode 100644 index 0000000..b736b22 --- /dev/null +++ b/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/apps/web/app/types/next-link.d.ts b/apps/web/app/types/next-link.d.ts new file mode 100644 index 0000000..c724e3a --- /dev/null +++ b/apps/web/app/types/next-link.d.ts @@ -0,0 +1,12 @@ +declare module "next/link" { + type Props = React.ComponentProps<"a"> & { + href: string; + replace?: boolean; + prefetch?: boolean; + scroll?: boolean; + shallow?: boolean; + }; + + const Link: React.FC; + export default Link; +} diff --git a/apps/web/app/types/next-navigation.d.ts b/apps/web/app/types/next-navigation.d.ts new file mode 100644 index 0000000..67a80c4 --- /dev/null +++ b/apps/web/app/types/next-navigation.d.ts @@ -0,0 +1,14 @@ +declare module "next/navigation" { + export function useRouter(): { + push: (url: string) => void; + replace: (url: string) => void; + back: () => void; + forward: () => void; + refresh: () => void; + prefetch: (url: string) => Promise; + }; + + export function usePathname(): string; + export function useSearchParams(): URLSearchParams; + export function useParams>(): T; +} diff --git a/apps/web/app/types/next-script.d.ts b/apps/web/app/types/next-script.d.ts new file mode 100644 index 0000000..299b5ed --- /dev/null +++ b/apps/web/app/types/next-script.d.ts @@ -0,0 +1,15 @@ +declare module "next/script" { + type ScriptProps = { + src?: string; + id?: string; + strategy?: "beforeInteractive" | "afterInteractive" | "lazyOnload" | "worker"; + onLoad?: () => void; + onError?: () => void; + children?: string; + defer?: boolean; + [key: string]: any; + }; + + const Script: React.FC; + export default Script; +} diff --git a/apps/web/app/types/react-router-virtual.d.ts b/apps/web/app/types/react-router-virtual.d.ts new file mode 100644 index 0000000..abf3b63 --- /dev/null +++ b/apps/web/app/types/react-router-virtual.d.ts @@ -0,0 +1,5 @@ +declare module "virtual:react-router/server-build" { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const build: any; + export default build; +} diff --git a/apps/web/caddy/Caddyfile b/apps/web/caddy/Caddyfile new file mode 100644 index 0000000..fced343 --- /dev/null +++ b/apps/web/caddy/Caddyfile @@ -0,0 +1,37 @@ +{ + servers { + trusted_proxies static private_ranges + } + + order rate_limit before file_server +} + +:3000 { + root * /usr/share/caddy/html + + # Per-client-IP rate limiting for static asset requests + rate_limit { + zone static { + key {client_ip} + events 300 + window 1m + } + } + + # Security headers (HSTS is set at the TLS terminator) + header { + X-Frame-Options "DENY" + X-Content-Type-Options "nosniff" + X-XSS-Protection "1; mode=block" + } + + # SPA fallback + try_files {path} /index.html + file_server + + # Access logs to stdout (default in Caddy) + log { + output stdout + format console + } +} diff --git a/apps/web/core/components/account/auth-forms/auth-header.tsx b/apps/web/core/components/account/auth-forms/auth-header.tsx new file mode 100644 index 0000000..239d658 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/auth-header.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 { observer } from "mobx-react"; +import useSWR from "swr"; +import { useTranslation } from "@plane/i18n"; +import type { IWorkspaceMemberInvitation } from "@plane/types"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { WorkspaceLogo } from "@/components/workspace/logo"; +// helpers +import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper"; +// services +import { WorkspaceService } from "@/services/workspace.service"; + +type TAuthHeader = { + workspaceSlug: string | undefined; + invitationId: string | undefined; + invitationEmail: string | undefined; + authMode: EAuthModes; + currentAuthStep: EAuthSteps; +}; + +const Titles = { + [EAuthModes.SIGN_IN]: { + [EAuthSteps.EMAIL]: { + header: "Work in all dimensions.", + subHeader: "Welcome back to Plane.", + }, + [EAuthSteps.PASSWORD]: { + header: "Work in all dimensions.", + subHeader: "Welcome back to Plane.", + }, + [EAuthSteps.UNIQUE_CODE]: { + header: "Work in all dimensions.", + subHeader: "Welcome back to Plane.", + }, + }, + [EAuthModes.SIGN_UP]: { + [EAuthSteps.EMAIL]: { + header: "Work in all dimensions.", + subHeader: "Create your Plane account.", + }, + [EAuthSteps.PASSWORD]: { + header: "Work in all dimensions.", + subHeader: "Create your Plane account.", + }, + [EAuthSteps.UNIQUE_CODE]: { + header: "Work in all dimensions.", + subHeader: "Create your Plane account.", + }, + }, +}; + +const workSpaceService = new WorkspaceService(); + +export const AuthHeader = observer(function AuthHeader(props: TAuthHeader) { + const { workspaceSlug, invitationId, invitationEmail, authMode, currentAuthStep } = props; + // plane imports + const { t } = useTranslation(); + + const { data: invitation, isLoading } = useSWR( + workspaceSlug && invitationId ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null, + async () => workspaceSlug && invitationId && workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId), + { + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); + + const getHeaderSubHeader = ( + step: EAuthSteps, + mode: EAuthModes, + invitation: IWorkspaceMemberInvitation | undefined, + email: string | undefined + ) => { + if (invitation && email && invitation.email === email && invitation.workspace) { + const workspace = invitation.workspace; + return { + header: ( +
+ {t("common.join")}{" "} + {" "} + {workspace.name} +
+ ), + subHeader: + mode == EAuthModes.SIGN_UP + ? "Create an account to start managing work with your team." + : "Log in to start managing work with your team.", + }; + } + + return Titles[mode][step]; + }; + + const { header, subHeader } = getHeaderSubHeader(currentAuthStep, authMode, invitation || undefined, invitationEmail); + + if (isLoading) + return ( +
+ +
+ ); + + return ; +}); + +type TAuthHeaderBase = { + header: React.ReactNode; + subHeader: string; +}; + +export function AuthHeaderBase(props: TAuthHeaderBase) { + return ( +
+ {props.header} + {props.subHeader} +
+ ); +} diff --git a/apps/web/core/components/account/auth-forms/auth-root.tsx b/apps/web/core/components/account/auth-forms/auth-root.tsx new file mode 100644 index 0000000..46ae2c4 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/auth-root.tsx @@ -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 { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// plane imports +import { Banner } from "@makeplane/propel/components/banner"; +import { OAuthOptions } from "@plane/ui"; +// helpers +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { + EAuthModes, + EAuthSteps, + EAuthenticationErrorCodes, + EErrorAlertType, + authErrorHandler, +} from "@/helpers/authentication.helper"; +// hooks +import { useOAuthConfig } from "@/hooks/oauth"; +import { useInstance } from "@/hooks/store/use-instance"; +// local imports +import { TermsAndConditions } from "../terms-and-conditions"; +import { AuthHeader, AuthHeaderBase } from "./auth-header"; +import { AuthFormRoot } from "./form-root"; + +type TAuthRoot = { + authMode: EAuthModes; +}; + +export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) { + //router + const searchParams = useSearchParams(); + // query params + const emailParam = searchParams.get("email"); + const invitation_id = searchParams.get("invitation_id"); + const workspaceSlug = searchParams.get("slug"); + const error_code = searchParams.get("error_code"); + // props + const { authMode: currentAuthMode } = props; + // states + const [authMode, setAuthMode] = useState(undefined); + const [authStep, setAuthStep] = useState(EAuthSteps.EMAIL); + const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); + const [errorInfo, setErrorInfo] = useState(undefined); + // store hooks + const { config } = useInstance(); + // derived values + const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in"; + const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText); + const isEmailBasedAuthEnabled = config?.is_email_password_enabled || config?.is_magic_login_enabled; + const noAuthMethodsAvailable = !isOAuthEnabled && !isEmailBasedAuthEnabled; + + useEffect(() => { + if (!authMode && currentAuthMode) setAuthMode(currentAuthMode); + }, [currentAuthMode, authMode]); + + useEffect(() => { + if (error_code && authMode) { + const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes); + if (errorhandler) { + // password error handler + if ([EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP].includes(errorhandler.code)) { + setAuthMode(EAuthModes.SIGN_UP); + setAuthStep(EAuthSteps.PASSWORD); + } + if ([EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN].includes(errorhandler.code)) { + setAuthMode(EAuthModes.SIGN_IN); + setAuthStep(EAuthSteps.PASSWORD); + } + // magic_code error handler + if ( + [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_UP, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_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); + } + if ( + [ + EAuthenticationErrorCodes.INVALID_MAGIC_CODE_SIGN_IN, + EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_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); + } + + setErrorInfo(errorhandler); + } + } + }, [error_code, authMode]); + + if (!authMode) return <>; + + if (noAuthMethodsAvailable) { + return ( + + + + ); + } + + return ( + + {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(undefined)} + /> + )} + + {isOAuthEnabled && ( + + )} + {isEmailBasedAuthEnabled && ( + setEmail(email)} + setAuthMode={(authMode) => setAuthMode(authMode)} + setAuthStep={(authStep) => setAuthStep(authStep)} + setErrorInfo={(errorInfo) => setErrorInfo(errorInfo)} + currentAuthMode={currentAuthMode} + /> + )} + + + ); +}); + +function AuthContainer({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/core/components/account/auth-forms/common/container.tsx b/apps/web/core/components/account/auth-forms/common/container.tsx new file mode 100644 index 0000000..ecf13f9 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/common/container.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. + */ + +export function FormContainer({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/core/components/account/auth-forms/common/header.tsx b/apps/web/core/components/account/auth-forms/common/header.tsx new file mode 100644 index 0000000..4639634 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/common/header.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 function AuthFormHeader({ title, description }: { title: string; description: string }) { + return ( +
+ {title} + {description} +
+ ); +} diff --git a/apps/web/core/components/account/auth-forms/email.tsx b/apps/web/core/components/account/auth-forms/email.tsx new file mode 100644 index 0000000..0fb17c2 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/email.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 type { FormEvent } from "react"; +import { useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +// icons +import { CloseCircleOutline, WarningCircleOutline } from "@makeplane/propel/icons"; +// plane imports +import { Field } from "@makeplane/propel/components/field"; +import { Input, InputGroup } from "@makeplane/propel/components/input"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import type { IEmailCheckData } from "@plane/types"; +import { Spinner } from "@plane/ui"; +import { checkEmailValidity } from "@plane/utils"; +// helpers +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); + // plane hooks + const { t } = useTranslation(); + const emailError = useMemo( + () => (email && !checkEmailValidity(email) ? { email: "auth.common.email.errors.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={t("auth.common.email.placeholder")} + autoComplete="off" + autoFocus + ref={inputRef} + /> + {email.length > 0 && ( + + )} + + + {emailError?.email && !isFocused && ( +

+ + {t(emailError.email)} +

+ )} +
+ +
+ ); +}); diff --git a/apps/web/core/components/account/auth-forms/forgot-password-popover.tsx b/apps/web/core/components/account/auth-forms/forgot-password-popover.tsx new file mode 100644 index 0000000..865e034 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/forgot-password-popover.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 { Fragment, useState } from "react"; +import { usePopper } from "react-popper"; +import { Popover } from "@headlessui/react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { CloseOutline } from "@makeplane/propel/icons"; + +export function ForgotPasswordPopover() { + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "right-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + // plane hooks + const { t } = useTranslation(); + + return ( + + + + + + {({ close }) => ( +
+ 🤥 +

{t("auth.forgot_password.errors.smtp_not_enabled")}

+ +
+ )} +
+
+ ); +} diff --git a/apps/web/core/components/account/auth-forms/forgot-password.tsx b/apps/web/core/components/account/auth-forms/forgot-password.tsx new file mode 100644 index 0000000..71ad471 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/forgot-password.tsx @@ -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 { observer } from "mobx-react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Controller, useForm } from "react-hook-form"; +// icons +import { TickCircleOutline } from "@makeplane/propel/icons"; +// plane imports +import { Field } from "@makeplane/propel/components/field"; +import { Input, InputGroup } from "@makeplane/propel/components/input"; +import { useTranslation } from "@plane/i18n"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; + +import { cn, checkEmailValidity } from "@plane/utils"; +// hooks +import useTimer from "@/hooks/use-timer"; +// services +import { AuthService } from "@/services/auth.service"; +// local components +import { FormContainer } from "./common/container"; +import { AuthFormHeader } from "./common/header"; + +type TForgotPasswordFormValues = { + email: string; +}; + +const defaultValues: TForgotPasswordFormValues = { + email: "", +}; + +// services +const authService = new AuthService(); + +export const ForgotPasswordForm = observer(function ForgotPasswordForm() { + // search params + const searchParams = useSearchParams(); + const email = searchParams.get("email"); + // plane hooks + const { t } = useTranslation(); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0); + + // form info + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ + defaultValues: { + ...defaultValues, + email: email?.toString() ?? "", + }, + }); + + const handleForgotPassword = async (formData: TForgotPasswordFormValues) => { + await authService + .sendResetPasswordLink({ + email: formData.email, + }) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("auth.forgot_password.toast.success.title"), + message: t("auth.forgot_password.toast.success.message"), + }); + setResendCodeTimer(30); + }) + .catch((err) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("auth.forgot_password.toast.error.title"), + message: err?.error ?? t("auth.forgot_password.toast.error.message"), + }); + }); + }; + + return ( + + +
+
+ + checkEmailValidity(value) || t("auth.common.email.errors.invalid"), + }} + render={({ field: { value, onChange, ref } }) => ( + + + 0} + /> + + + )} + /> + {resendTimerCode > 0 && ( +

+ + {t("auth.forgot_password.email_sent")} +

+ )} +
+ + + {t("auth.common.back_to_sign_in")} + +
+
+ ); +}); diff --git a/apps/web/core/components/account/auth-forms/form-root.tsx b/apps/web/core/components/account/auth-forms/form-root.tsx new file mode 100644 index 0000000..ab3aa83 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/form-root.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 React, { useState } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import { EAuthModes, EAuthSteps } from "@plane/constants"; +import type { IEmailCheckData } from "@plane/types"; +// helpers +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { authErrorHandler } from "@/helpers/authentication.helper"; +// hooks +import { useInstance } from "@/hooks/store/use-instance"; +import { useAppRouter } from "@/hooks/use-app-router"; +// services +import { AuthService } from "@/services/auth.service"; +// local components +import { AuthEmailForm } from "./email"; +import { AuthPasswordForm } from "./password"; +import { AuthUniqueCodeForm } from "./unique-code"; + +type TAuthFormRoot = { + authStep: EAuthSteps; + authMode: EAuthModes; + email: string; + setEmail: (email: string) => void; + setAuthMode: (authMode: EAuthModes) => void; + setAuthStep: (authStep: EAuthSteps) => void; + setErrorInfo: (errorInfo: TAuthErrorInfo | undefined) => void; + currentAuthMode: EAuthModes; +}; + +const authService = new AuthService(); + +export const AuthFormRoot = observer(function AuthFormRoot(props: TAuthFormRoot) { + const { authStep, authMode, email, setEmail, setAuthMode, setAuthStep, setErrorInfo, currentAuthMode } = props; + // router + const router = useAppRouter(); + // query params + const searchParams = useSearchParams(); + const nextPath = searchParams.get("next_path"); + // states + const [isExistingEmail, setIsExistingEmail] = useState(false); + // hooks + const { config } = useInstance(); + + const isSMTPConfigured = config?.is_smtp_configured || false; + + // submit handler- email verification + const handleEmailVerification = async (data: IEmailCheckData) => { + setEmail(data.email); + setErrorInfo(undefined); + await authService + .emailCheck(data) + .then(async (response) => { + if (response.existing) { + if (currentAuthMode === EAuthModes.SIGN_UP) setAuthMode(EAuthModes.SIGN_IN); + if (response.status === "MAGIC_CODE") { + setAuthStep(EAuthSteps.UNIQUE_CODE); + generateEmailUniqueCode(data.email); + } else if (response.status === "CREDENTIAL") { + setAuthStep(EAuthSteps.PASSWORD); + } + } else { + if (currentAuthMode === EAuthModes.SIGN_IN) setAuthMode(EAuthModes.SIGN_UP); + if (response.status === "MAGIC_CODE") { + setAuthStep(EAuthSteps.UNIQUE_CODE); + generateEmailUniqueCode(data.email); + } else if (response.status === "CREDENTIAL") { + setAuthStep(EAuthSteps.PASSWORD); + } + } + setIsExistingEmail(response.existing); + }) + .catch((error) => { + const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined); + if (errorhandler?.type) setErrorInfo(errorhandler); + }); + }; + + const handleEmailClear = () => { + setAuthMode(currentAuthMode); + setErrorInfo(undefined); + setEmail(""); + setAuthStep(EAuthSteps.EMAIL); + router.push(currentAuthMode === EAuthModes.SIGN_IN ? `/` : "/sign-up"); + }; + + // generating the unique code + const generateEmailUniqueCode = async (email: string): Promise<{ code: string } | undefined> => { + if (!isSMTPConfigured) return; + 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; + }); + }; + + if (authStep === EAuthSteps.EMAIL) { + return ; + } + if (authStep === EAuthSteps.UNIQUE_CODE) { + return ( + + ); + } + if (authStep === EAuthSteps.PASSWORD) { + return ( + { + if (step === EAuthSteps.UNIQUE_CODE) generateEmailUniqueCode(email); + setAuthStep(step); + }} + nextPath={nextPath || undefined} + /> + ); + } + + return <>; +}); diff --git a/apps/web/core/components/account/auth-forms/index.ts b/apps/web/core/components/account/auth-forms/index.ts new file mode 100644 index 0000000..125f669 --- /dev/null +++ b/apps/web/core/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/apps/web/core/components/account/auth-forms/password.tsx b/apps/web/core/components/account/auth-forms/password.tsx new file mode 100644 index 0000000..9b8684c --- /dev/null +++ b/apps/web/core/components/account/auth-forms/password.tsx @@ -0,0 +1,304 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +// icons +import { CloseCircleOutline, HideOutline, ShowOutline } from "@makeplane/propel/icons"; +// plane imports +import { Banner } from "@makeplane/propel/components/banner"; +import { Input, InputGroup } from "@makeplane/propel/components/input"; +import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { PasswordStrengthIndicator, Spinner } from "@plane/ui"; +import { getPasswordStrength } from "@plane/utils"; +// components +import { ForgotPasswordPopover } from "@/components/account/auth-forms/forgot-password-popover"; +// constants +// helpers +import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper"; +// services +import { AuthService } from "@/services/auth.service"; + +type Props = { + email: string; + isSMTPConfigured: boolean; + mode: EAuthModes; + handleEmailClear: () => void; + handleAuthStep: (step: EAuthSteps) => void; + nextPath: string | undefined; +}; + +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, isSMTPConfigured, handleAuthStep, handleEmailClear, mode, nextPath } = props; + // plane imports + const { t } = useTranslation(); + // 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 [isBannerMessage, setBannerMessage] = 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 = + mode === EAuthModes.SIGN_IN ? ( +
+ {isSMTPConfigured ? ( + + {t("auth.common.forgot_password")} + + ) : ( + + )} +
+ ) : ( + passwordFormData.password.length > 0 && + getPasswordStrength(passwordFormData.password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && ( + + ) + ); + + const isButtonDisabled = useMemo( + () => + !isSubmitting && + !!passwordFormData.password && + (mode === EAuthModes.SIGN_UP ? 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 ( + <> + {isBannerMessage && mode === EAuthModes.SIGN_UP && ( + setBannerMessage(false)} + /> + )} +
{ + event.preventDefault(); // Prevent form from submitting by default + await handleCSRFToken(); + const isPasswordValid = + mode === EAuthModes.SIGN_UP + ? getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID + : true; + if (isPasswordValid) { + setIsSubmitting(true); + if (formRef.current) formRef.current.submit(); // Manually submit the form if the condition is met + } else { + setBannerMessage(true); + } + }} + onError={() => { + setIsSubmitting(false); + }} + > + + + {nextPath && } +
+ + + handleFormChange("email", e.target.value)} + placeholder={t("auth.common.email.placeholder")} + disabled + /> + {passwordFormData.email.length > 0 && ( + + )} + +
+ +
+ + + handleFormChange("password", e.target.value)} + placeholder={t("auth.common.password.placeholder")} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoComplete="off" + autoFocus + /> + + + {passwordSupport} +
+ + {mode === EAuthModes.SIGN_UP && ( +
+ + + handleFormChange("confirm_password", e.target.value)} + placeholder={t("auth.common.password.confirm_password.placeholder")} + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + autoComplete="off" + /> + + + {!!passwordFormData.confirm_password && + passwordFormData.password !== passwordFormData.confirm_password && + renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )} +
+ )} + +
+ {mode === EAuthModes.SIGN_IN ? ( + <> + + {isSMTPConfigured && ( + + )} + + ) : ( + + )} +
+
+ + ); +}); diff --git a/apps/web/core/components/account/auth-forms/reset-password.tsx b/apps/web/core/components/account/auth-forms/reset-password.tsx new file mode 100644 index 0000000..a11b05b --- /dev/null +++ b/apps/web/core/components/account/auth-forms/reset-password.tsx @@ -0,0 +1,211 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// icons +import { HideOutline, ShowOutline } from "@makeplane/propel/icons"; +// ui +import { Banner } from "@makeplane/propel/components/banner"; +import { Input, InputGroup } from "@makeplane/propel/components/input"; +import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { PasswordStrengthIndicator } from "@plane/ui"; +// components +import { getPasswordStrength } from "@plane/utils"; +// helpers +import type { EAuthenticationErrorCodes, TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { EErrorAlertType, authErrorHandler } from "@/helpers/authentication.helper"; +// services +import { AuthService } from "@/services/auth.service"; +// local imports +import { FormContainer } from "./common/container"; +import { AuthFormHeader } from "./common/header"; + +type TResetPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TResetPasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +export const ResetPasswordForm = observer(function ResetPasswordForm() { + // search params + const searchParams = useSearchParams(); + const uidb64 = searchParams.get("uidb64"); + const token = searchParams.get("token"); + const email = searchParams.get("email"); + const error_code = searchParams.get("error_code"); + // states + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); + const [resetFormData, setResetFormData] = useState({ + ...defaultValues, + email: email ? email.toString() : "", + }); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + const [errorInfo, setErrorInfo] = useState(undefined); + // plane hooks + const { t } = useTranslation(); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) => + setResetFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const isButtonDisabled = useMemo( + () => + !!resetFormData.password && + getPasswordStrength(resetFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID && + resetFormData.password === resetFormData.confirm_password + ? false + : true, + [resetFormData] + ); + + useEffect(() => { + if (error_code) { + const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes); + if (errorhandler) { + setErrorInfo(errorhandler); + } + } + }, [error_code]); + + const password = resetFormData?.password ?? ""; + const confirmPassword = resetFormData?.confirm_password ?? ""; + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + return ( + + + + {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(undefined)} + /> + )} +
+ +
+ + + + +
+
+ + + handleFormChange("password", e.target.value)} + placeholder={t("auth.common.password.placeholder")} + minLength={8} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoComplete="new-password" + autoFocus + /> + + + +
+
+ + + handleFormChange("confirm_password", e.target.value)} + placeholder={t("auth.common.password.confirm_password.placeholder")} + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + autoComplete="new-password" + /> + + + {!!resetFormData.confirm_password && + resetFormData.password !== resetFormData.confirm_password && + renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )} +
+ +
+
+ ); +}); diff --git a/apps/web/core/components/account/auth-forms/set-password.tsx b/apps/web/core/components/account/auth-forms/set-password.tsx new file mode 100644 index 0000000..044d9b0 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/set-password.tsx @@ -0,0 +1,208 @@ +/** + * 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 { useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// icons +import { HideOutline, ShowOutline } from "@makeplane/propel/icons"; +// plane imports +import { Input, InputGroup } from "@makeplane/propel/components/input"; +import { E_PASSWORD_STRENGTH } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { PasswordStrengthIndicator } from "@plane/ui"; +// components +import { getPasswordStrength } from "@plane/utils"; +// hooks +import { useUser } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// services +import { AuthService } from "@/services/auth.service"; +// local components +import { FormContainer } from "./common/container"; +import { AuthFormHeader } from "./common/header"; + +type TResetPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TResetPasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +export const SetPasswordForm = observer(function SetPasswordForm() { + // router + const router = useAppRouter(); + // search params + const searchParams = useSearchParams(); + const email = searchParams.get("email"); + // states + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); + const [passwordFormData, setPasswordFormData] = useState({ + ...defaultValues, + email: email ? email.toString() : "", + }); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + // plane hooks + const { t } = useTranslation(); + // hooks + const { data: user, handleSetPassword } = useUser(); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) => + setPasswordFormData((prev) => ({ ...prev, [key]: value })); + + const isButtonDisabled = useMemo( + () => + !!passwordFormData.password && + getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID && + passwordFormData.password === passwordFormData.confirm_password + ? false + : true, + [passwordFormData] + ); + + const handleSubmit = async (e: FormEvent) => { + try { + e.preventDefault(); + if (!csrfToken) throw new Error("csrf token not found"); + await handleSetPassword(csrfToken, { password: passwordFormData.password }); + router.push("/"); + } 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: t("common.errors.default.title"), + message: message ?? t("common.errors.default.message"), + }); + } + }; + + const password = passwordFormData?.password ?? ""; + const confirmPassword = passwordFormData?.confirm_password ?? ""; + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + return ( + + +
handleSubmit(e)}> +
+ + + + +
+
+ + + handleFormChange("password", e.target.value)} + placeholder={t("auth.common.password.placeholder")} + minLength={8} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoComplete="new-password" + autoFocus + /> + + + +
+
+ + + handleFormChange("confirm_password", e.target.value)} + placeholder={t("auth.common.password.confirm_password.placeholder")} + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + autoComplete="new-password" + /> + + + {!!passwordFormData.confirm_password && + passwordFormData.password !== passwordFormData.confirm_password && + renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )} +
+ +
+
+ ); +}); diff --git a/apps/web/core/components/account/auth-forms/unique-code.tsx b/apps/web/core/components/account/auth-forms/unique-code.tsx new file mode 100644 index 0000000..d3a4569 --- /dev/null +++ b/apps/web/core/components/account/auth-forms/unique-code.tsx @@ -0,0 +1,180 @@ +/** + * 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 { CloseCircleOutline, TickCircleOutline } from "@makeplane/propel/icons"; +import { Input, InputGroup } from "@makeplane/propel/components/input"; +import { API_BASE_URL } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { Spinner } from "@plane/ui"; +// constants +// helpers +import { EAuthModes } from "@/helpers/authentication.helper"; +// hooks +import useTimer from "@/hooks/use-timer"; +// services +import { AuthService } from "@/services/auth.service"; + +// services +const authService = new AuthService(); + +type TAuthUniqueCodeForm = { + mode: EAuthModes; + email: string; + isExistingEmail: boolean; + handleEmailClear: () => void; + generateEmailUniqueCode: (email: string) => Promise<{ code: string } | undefined>; + nextPath: string | undefined; +}; + +type TUniqueCodeFormValues = { + email: string; + code: string; +}; + +const defaultValues: TUniqueCodeFormValues = { + email: "", + code: "", +}; + +export function AuthUniqueCodeForm(props: TAuthUniqueCodeForm) { + const { mode, email, handleEmailClear, generateEmailUniqueCode, nextPath } = 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); + // plane hooks + const { t } = useTranslation(); + + 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); + }} + > + + + {nextPath && } +
+ + + handleFormChange("email", e.target.value)} + placeholder={t("auth.common.email.placeholder")} + autoComplete="off" + disabled + /> + {uniqueCodeFormData.email.length > 0 && ( + + )} + +
+ +
+ + + handleFormChange("code", e.target.value)} + placeholder={t("auth.common.unique_code.placeholder")} + autoComplete="off" + autoFocus + /> + +
+

+ + {t("auth.common.unique_code.paste_code")} +

+ +
+
+ +
+ +
+
+ ); +} diff --git a/apps/web/core/components/account/deactivate-account-modal.tsx b/apps/web/core/components/account/deactivate-account-modal.tsx new file mode 100644 index 0000000..ed18aba --- /dev/null +++ b/apps/web/core/components/account/deactivate-account-modal.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 { useState } from "react"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button } from "@plane/propel/button"; +import { DeleteOutline } from "@makeplane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; +// hooks +import { useUser } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +export function DeactivateAccountModal(props: Props) { + const router = useAppRouter(); + const { isOpen, onClose } = props; + // hooks + const { t } = useTranslation(); + const { deactivateAccount, signOut } = useUser(); + + // states + const [isDeactivating, setIsDeactivating] = useState(false); + + const handleClose = () => { + setIsDeactivating(false); + onClose(); + }; + + const handleDeleteAccount = async () => { + setIsDeactivating(true); + + await deactivateAccount() + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Account deactivated successfully.", + }); + signOut(); + router.push("/"); + handleClose(); + return; + }) + .catch((err: any) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.error, + }); + }) + .finally(() => setIsDeactivating(false)); + }; + + return ( + +
+
+
+
+
+
+

{t("deactivate_your_account")}

+

+ {t("deactivate_your_account_description")} +

+
+
+
+
+
+ + +
+
+ ); +} diff --git a/apps/web/core/components/account/terms-and-conditions.tsx b/apps/web/core/components/account/terms-and-conditions.tsx new file mode 100644 index 0000000..f6be596 --- /dev/null +++ b/apps/web/core/components/account/terms-and-conditions.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 React from "react"; +import Link from "next/link"; +import { EAuthModes } from "@plane/constants"; + +interface TermsAndConditionsProps { + authType?: EAuthModes; +} + +// Constants for better maintainability +const LEGAL_LINKS = { + termsOfService: "https://plane.so/legals/terms-and-conditions", + privacyPolicy: "https://plane.so/legals/privacy-policy", +} as const; + +const MESSAGES = { + [EAuthModes.SIGN_UP]: "By creating an account", + [EAuthModes.SIGN_IN]: "By signing in", +} as const; + +// Reusable link component to reduce duplication +function LegalLink({ href, children }: { href: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function TermsAndConditions({ authType = EAuthModes.SIGN_IN }: TermsAndConditionsProps) { + return ( +
+

+ {`${MESSAGES[authType]}, you understand and agree to \n our `} + Terms of Service and{" "} + Privacy Policy. +

+
+ ); +} diff --git a/apps/web/core/components/active-cycles/workspace-active-cycles-upgrade.tsx b/apps/web/core/components/active-cycles/workspace-active-cycles-upgrade.tsx new file mode 100644 index 0000000..6977d89 --- /dev/null +++ b/apps/web/core/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 { CircleDashed, Microscope } from "lucide-react"; +import { AlertOctagonOutline, BarOutline, FolderOutline, SearchOutline } from "@makeplane/propel/icons"; +// plane imports +import { MARKETING_PRICING_PAGE_LINK } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { getButtonStyling } from "@plane/propel/button"; +import { ContentWrapper } from "@plane/ui"; +import { cn } from "@plane/utils"; +// assets +import ctaL1Dark from "@/app/assets/workspace-active-cycles/cta-l-1-dark.webp?url"; +import ctaL1Light from "@/app/assets/workspace-active-cycles/cta-l-1-light.webp?url"; +import ctaR1Dark from "@/app/assets/workspace-active-cycles/cta-r-1-dark.webp?url"; +import ctaR1Light from "@/app/assets/workspace-active-cycles/cta-r-1-light.webp?url"; +import ctaR2Dark from "@/app/assets/workspace-active-cycles/cta-r-2-dark.webp?url"; +import ctaR2Light from "@/app/assets/workspace-active-cycles/cta-r-2-light.webp?url"; +// components +import { ProIcon } from "@/components/common/pro-icon"; +// hooks +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: FolderOutline, + }, + { + 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: BarOutline, + }, + { + 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: AlertOctagonOutline, + }, + { + 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: SearchOutline, + }, + { + 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/apps/web/core/components/analytics/analytics-filter-actions.tsx b/apps/web/core/components/analytics/analytics-filter-actions.tsx new file mode 100644 index 0000000..1f9a3d2 --- /dev/null +++ b/apps/web/core/components/analytics/analytics-filter-actions.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. + */ + +// plane web components +import { observer } from "mobx-react"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +import { useProject } from "@/hooks/store/use-project"; +// components +import { ProjectSelect } from "./select/project"; + +const AnalyticsFilterActions = observer(function AnalyticsFilterActions() { + const { selectedProjects, updateSelectedProjects } = useAnalytics(); + const { joinedProjectIds } = useProject(); + return ( +
+ { + updateSelectedProjects(val ?? []); + }} + projectIds={joinedProjectIds} + /> + {/* { + updateSelectedDuration(val); + }} + dropdownArrow + /> */} +
+ ); +}); + +export default AnalyticsFilterActions; diff --git a/apps/web/core/components/analytics/analytics-section-wrapper.tsx b/apps/web/core/components/analytics/analytics-section-wrapper.tsx new file mode 100644 index 0000000..d46ce74 --- /dev/null +++ b/apps/web/core/components/analytics/analytics-section-wrapper.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 { cn } from "@plane/utils"; + +type Props = { + title?: string; + children: React.ReactNode; + className?: string; + subtitle?: string | null; + actions?: React.ReactNode; + headerClassName?: string; +}; + +function AnalyticsSectionWrapper(props: Props) { + const { title, children, className, actions, headerClassName } = props; + return ( +
+
+ {title && ( +
+

{title}

+ {/* {subtitle &&

• {subtitle}

} */} +
+ )} + {actions} +
+ {children} +
+ ); +} + +export default AnalyticsSectionWrapper; diff --git a/apps/web/core/components/analytics/analytics-wrapper.tsx b/apps/web/core/components/analytics/analytics-wrapper.tsx new file mode 100644 index 0000000..6cd886b --- /dev/null +++ b/apps/web/core/components/analytics/analytics-wrapper.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 React from "react"; +// plane package imports +import { useTranslation } from "@plane/i18n"; +import { cn } from "@plane/utils"; + +type Props = { + i18nTitle: string; + children: React.ReactNode; + className?: string; +}; + +function AnalyticsWrapper(props: Props) { + const { i18nTitle, children, className } = props; + const { t } = useTranslation(); + return ( +
+

{t(i18nTitle)}

+ {children} +
+ ); +} + +export default AnalyticsWrapper; diff --git a/apps/web/core/components/analytics/empty-state.tsx b/apps/web/core/components/analytics/empty-state.tsx new file mode 100644 index 0000000..8c2a721 --- /dev/null +++ b/apps/web/core/components/analytics/empty-state.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 { useTheme } from "next-themes"; +// plane package imports +import { cn } from "@plane/utils"; +// assets +import darkBackgroundAsset from "@/app/assets/empty-state/analytics/empty-grid-background-dark.webp?url"; +import lightBackgroundAsset from "@/app/assets/empty-state/analytics/empty-grid-background-light.webp?url"; + +type Props = { + title: string; + description?: string; + assetPath?: string; + className?: string; +}; + +function AnalyticsEmptyState({ title, description, assetPath, className }: Props) { + // theme hook + const { resolvedTheme } = useTheme(); + const backgroundReolvedPath = resolvedTheme === "light" ? lightBackgroundAsset : darkBackgroundAsset; + + return ( +
+
+ {assetPath && ( +
+ {title} +
+ {title} +
+
+ )} +
+

{title}

+ {description &&

{description}

} +
+
+
+ ); +} + +export default AnalyticsEmptyState; diff --git a/apps/web/core/components/analytics/export.ts b/apps/web/core/components/analytics/export.ts new file mode 100644 index 0000000..c77d178 --- /dev/null +++ b/apps/web/core/components/analytics/export.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. + */ + +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { download, generateCsv, mkConfig } from "export-to-csv"; + +export const csvConfig = (workspaceSlug: string) => + mkConfig({ + fieldSeparator: ",", + filename: `${workspaceSlug}-analytics`, + decimalSeparator: ".", + useKeysAsHeaders: true, + }); + +export const exportCSV = (rows: Row[], columns: ColumnDef[], workspaceSlug: string) => { + const rowData = rows.map((row) => { + const exportColumns = columns.map((col) => col.meta?.export); + const cells = exportColumns.reduce((acc: Record, col) => { + if (col) { + const cell = col?.value(row) ?? "-"; + acc[col.label ?? col.key] = cell; + } + return acc; + }, {}); + return cells; + }); + const csv = generateCsv(csvConfig(workspaceSlug))(rowData); + download(csvConfig(workspaceSlug))(csv); +}; diff --git a/apps/web/core/components/analytics/insight-card.tsx b/apps/web/core/components/analytics/insight-card.tsx new file mode 100644 index 0000000..d8a30a1 --- /dev/null +++ b/apps/web/core/components/analytics/insight-card.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. + */ + +// plane package imports +import React from "react"; +import type { IAnalyticsResponseFields } from "@plane/types"; +import { Loader } from "@plane/ui"; + +export type InsightCardProps = { + data?: IAnalyticsResponseFields; + label: string; + isLoading?: boolean; +}; + +function InsightCard(props: InsightCardProps) { + const { data, label, isLoading = false } = props; + const count = data?.count ?? 0; + + return ( +
+
{label}
+ {!isLoading ? ( +
+
{count}
+
+ ) : ( + + )} +
+ ); +} + +export default InsightCard; diff --git a/apps/web/core/components/analytics/insight-table/data-table.tsx b/apps/web/core/components/analytics/insight-table/data-table.tsx new file mode 100644 index 0000000..679e98a --- /dev/null +++ b/apps/web/core/components/analytics/insight-table/data-table.tsx @@ -0,0 +1,175 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import * as React from "react"; +import type { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + Table as TanstackTable, +} from "@tanstack/react-table"; +import { + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { useTranslation } from "@plane/i18n"; +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import { CloseOutline, SearchOutline } from "@makeplane/propel/icons"; +// plane package imports +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table"; +import { cn } from "@plane/utils"; +// plane web components + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + searchPlaceholder: string; + actions?: (table: TanstackTable) => React.ReactNode; +} + +export function DataTable({ columns, data, searchPlaceholder, actions }: DataTableProps) { + const [rowSelection, _setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = React.useState({}); + const [columnFilters, setColumnFilters] = React.useState([]); + const [sorting, _setSorting] = React.useState([]); + const { t } = useTranslation(); + const inputRef = React.useRef(null); + const [isSearchOpen, setIsSearchOpen] = React.useState(false); + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + }, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }); + + return ( +
+
+
+ {table.getHeaderGroups()?.[0]?.headers?.[0]?.id && ( +
+ {searchPlaceholder} +
+ )} + {!isSearchOpen && ( + + )} +
+ + { + const columnId = table.getHeaderGroups()?.[0]?.headers?.[0]?.id; + if (columnId) table.getColumn(columnId)?.setFilterValue(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + setIsSearchOpen(true); + } + }} + /> + {isSearchOpen && ( + + )} +
+
+ {actions &&
{actions(table)}
} +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : (flexRender(header.column.columnDef.header, header.getContext()) as any)} + + ))} + + ))} + + + {table.getRowModel().rows?.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext()) as any} + + ))} + + )) + ) : ( + + + + + + )} + +
+
+
+ ); +} diff --git a/apps/web/core/components/analytics/insight-table/index.ts b/apps/web/core/components/analytics/insight-table/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/apps/web/core/components/analytics/insight-table/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/apps/web/core/components/analytics/insight-table/loader.tsx b/apps/web/core/components/analytics/insight-table/loader.tsx new file mode 100644 index 0000000..e07da23 --- /dev/null +++ b/apps/web/core/components/analytics/insight-table/loader.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 * as React from "react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table"; +import { Loader } from "@plane/ui"; + +interface TableSkeletonProps { + columns: ColumnDef[]; + rows: number; +} + +export function TableLoader({ columns, rows }: TableSkeletonProps) { + return ( + + + + {columns.map((column, index) => ( + + {typeof column.header === "string" ? column.header : ""} + + ))} + + + + {Array.from({ length: rows }).map((_, rowIndex) => ( + + {columns.map((_, colIndex) => ( + + + + ))} + + ))} + +
+ ); +} diff --git a/apps/web/core/components/analytics/insight-table/root.tsx b/apps/web/core/components/analytics/insight-table/root.tsx new file mode 100644 index 0000000..4a368be --- /dev/null +++ b/apps/web/core/components/analytics/insight-table/root.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 { ColumnDef, Row, Table } from "@tanstack/react-table"; +import { DownloadOutline } from "@makeplane/propel/icons"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import type { AnalyticsTableDataMap, TAnalyticsTabsBase } from "@plane/types"; +import { DataTable } from "./data-table"; +import { TableLoader } from "./loader"; +interface InsightTableProps> { + analyticsType: T; + data?: AnalyticsTableDataMap[T][]; + isLoading?: boolean; + columns: ColumnDef[]; + columnsLabels?: Record; + headerText: string; + onExport?: (rows: Row[]) => void; +} + +export function InsightTable>( + props: InsightTableProps +): React.ReactElement { + const { data, isLoading, columns, headerText, onExport } = props; + const { t } = useTranslation(); + if (isLoading) { + return ; + } + + return ( +
+ ) => ( + + )} + /> +
+ ); +} diff --git a/apps/web/core/components/analytics/loaders.tsx b/apps/web/core/components/analytics/loaders.tsx new file mode 100644 index 0000000..c298dd0 --- /dev/null +++ b/apps/web/core/components/analytics/loaders.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 { Loader } from "@plane/ui"; + +export function ProjectInsightsLoader() { + return ( +
+ + + +
+ + + + + + +
+
+ ); +} + +export function ChartLoader() { + return ( + + + + ); +} diff --git a/apps/web/core/components/analytics/overview/active-project-item.tsx b/apps/web/core/components/analytics/overview/active-project-item.tsx new file mode 100644 index 0000000..14e97fd --- /dev/null +++ b/apps/web/core/components/analytics/overview/active-project-item.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. + */ + +// plane package imports +import { Logo } from "@plane/propel/emoji-icon-picker"; +import { ProjectsOutline } from "@makeplane/propel/icons"; +import { Tooltip } from "@makeplane/propel/components/tooltip"; +import { cn } from "@plane/utils"; +// plane web hooks +import { useProject } from "@/hooks/store/use-project"; + +type Props = { + project: { + id: string; + completed_issues?: number; + total_issues?: number; + }; + isLoading?: boolean; +}; + +function CompletionPercentage({ percentage }: { percentage: number }) { + const percentageColor = + percentage > 50 ? "bg-success-subtle text-success-primary" : "bg-danger-subtle text-danger-primary"; + return ( +
+ {percentage}% +
+ ); +} + +function ActiveProjectItem(props: Props) { + const { project } = props; + const { getProjectById } = useProject(); + const { id, completed_issues, total_issues } = project; + + const projectDetails = getProjectById(id); + + if (!projectDetails) return null; + + return ( +
+
+
+ + {projectDetails?.logo_props ? ( + + ) : ( + + + + )} + +
+ +

{projectDetails?.name}

+
+
+ +
+ ); +} + +export default ActiveProjectItem; diff --git a/apps/web/core/components/analytics/overview/active-projects.tsx b/apps/web/core/components/analytics/overview/active-projects.tsx new file mode 100644 index 0000000..1ad22dc --- /dev/null +++ b/apps/web/core/components/analytics/overview/active-projects.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 React from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane package imports +import { useTranslation } from "@plane/i18n"; +import { Loader } from "@plane/ui"; +// plane web hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +import { useProject } from "@/hooks/store/use-project"; +// plane web components +import AnalyticsSectionWrapper from "../analytics-section-wrapper"; +import ActiveProjectItem from "./active-project-item"; + +const ActiveProjects = observer(function ActiveProjects() { + const { t } = useTranslation(); + const { fetchProjectAnalyticsCount } = useProject(); + const { workspaceSlug } = useParams(); + const { selectedDurationLabel } = useAnalytics(); + const { data: projectAnalyticsCount, isLoading: isProjectAnalyticsCountLoading } = useSWR( + workspaceSlug ? ["projectAnalyticsCount", workspaceSlug] : null, + workspaceSlug + ? () => + fetchProjectAnalyticsCount(workspaceSlug.toString(), { + fields: "total_work_items,total_completed_work_items", + }) + : null + ); + return ( + +
+ {isProjectAnalyticsCountLoading && + Array.from({ length: 5 }).map((_, index) => )} + {!isProjectAnalyticsCountLoading && + projectAnalyticsCount?.map((project) => )} +
+
+ ); +}); + +export default ActiveProjects; diff --git a/apps/web/core/components/analytics/overview/index.ts b/apps/web/core/components/analytics/overview/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/apps/web/core/components/analytics/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 "./root"; diff --git a/apps/web/core/components/analytics/overview/project-insights.tsx b/apps/web/core/components/analytics/overview/project-insights.tsx new file mode 100644 index 0000000..6987ce1 --- /dev/null +++ b/apps/web/core/components/analytics/overview/project-insights.tsx @@ -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 { lazy, Suspense } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane package imports +import { useTranslation } from "@plane/i18n"; +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import type { TChartData } from "@plane/types"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +// services +import { AnalyticsService } from "@/services/analytics.service"; +// plane web components +import AnalyticsSectionWrapper from "../analytics-section-wrapper"; +import { ProjectInsightsLoader } from "../loaders"; + +const RadarChart = lazy(function RadarChart() { + return import("@plane/propel/charts/radar-chart").then((mod) => ({ + default: mod.RadarChart, + })); +}); + +const analyticsService = new AnalyticsService(); + +const ProjectInsights = observer(function ProjectInsights() { + const params = useParams(); + const { t } = useTranslation(); + const workspaceSlug = params.workspaceSlug.toString(); + const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } = + useAnalytics(); + + const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR( + `radar-chart-project-insights-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`, + () => + analyticsService.getAdvanceAnalyticsCharts[]>( + workspaceSlug, + "projects", + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + }, + isPeekView + ) + ); + + return ( + + {isLoadingProjectInsight ? ( + + ) : projectInsightsData && projectInsightsData?.length == 0 ? ( + + ) : ( +
+ {projectInsightsData && ( + }> + + + )} +
+
{t("workspace_analytics.summary_of_projects")}
+
{t("workspace_analytics.all_projects")}
+
+
+
{t("workspace_analytics.trend_on_charts")}
+
{t("common.work_items")}
+
+ {projectInsightsData?.map((item) => ( +
+
{item.name}
+
+ {/* */} +
{item.count}
+
+
+ ))} +
+
+
+ )} +
+ ); +}); + +export default ProjectInsights; diff --git a/apps/web/core/components/analytics/overview/root.tsx b/apps/web/core/components/analytics/overview/root.tsx new file mode 100644 index 0000000..4b191ca --- /dev/null +++ b/apps/web/core/components/analytics/overview/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 AnalyticsWrapper from "../analytics-wrapper"; +import TotalInsights from "../total-insights"; +import ActiveProjects from "./active-projects"; +import ProjectInsights from "./project-insights"; + +function Overview() { + return ( + +
+ +
+ + +
+
+
+ ); +} + +export { Overview }; diff --git a/apps/web/core/components/analytics/select/analytics-params.tsx b/apps/web/core/components/analytics/select/analytics-params.tsx new file mode 100644 index 0000000..0f266fb --- /dev/null +++ b/apps/web/core/components/analytics/select/analytics-params.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 { useMemo } from "react"; +import { observer } from "mobx-react"; +import type { Control, UseFormSetValue } from "react-hook-form"; +import { Controller } from "react-hook-form"; +import { CalendarOutline, PreferencesOutline } from "@makeplane/propel/icons"; +// plane package imports +import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "@plane/constants"; +import type { IAnalyticsParams } from "@plane/types"; +import { ChartYAxisMetric } from "@plane/types"; +import { cn } from "@plane/utils"; +// plane web components +import { SelectXAxis } from "./select-x-axis"; +import { SelectYAxis } from "./select-y-axis"; + +type Props = { + control: Control; + setValue: UseFormSetValue; + params: IAnalyticsParams; + workspaceSlug: string; + classNames?: string; + isEpic?: boolean; +}; + +export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(props: Props) { + const { control, params, classNames, isEpic } = props; + const xAxisOptions = useMemo( + () => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.group_by), + [params.group_by] + ); + const groupByOptions = useMemo( + () => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.x_axis), + [params.x_axis] + ); + + return ( +
+
+ ( + { + onChange(val); + }} + options={ANALYTICS_Y_AXIS_VALUES} + hiddenOptions={[ + ChartYAxisMetric.ESTIMATE_POINT_COUNT, + isEpic ? ChartYAxisMetric.WORK_ITEM_COUNT : ChartYAxisMetric.EPIC_WORK_ITEM_COUNT, + ]} + /> + )} + /> + ( + { + onChange(val); + }} + label={ +
+ + + {xAxisOptions.find((v) => v.value === value)?.label || "Add Property"} + +
+ } + options={xAxisOptions} + /> + )} + /> + ( + { + onChange(val); + }} + label={ +
+ + + {groupByOptions.find((v) => v.value === value)?.label || "Add Property"} + +
+ } + options={groupByOptions} + placeholder="Group By" + allowNoValue + /> + )} + /> +
+
+ ); +}); diff --git a/apps/web/core/components/analytics/select/duration.tsx b/apps/web/core/components/analytics/select/duration.tsx new file mode 100644 index 0000000..7748484 --- /dev/null +++ b/apps/web/core/components/analytics/select/duration.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. + */ + +// plane package imports +import type { ReactNode } from "react"; +import React from "react"; +import { CalendarOutline } from "@makeplane/propel/icons"; +// plane package imports +import { ANALYTICS_DURATION_FILTER_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { CustomSearchSelect } from "@plane/ui"; +// types +import type { TDropdownProps } from "@/components/dropdowns/types"; + +type Props = TDropdownProps & { + value: string | null; + onChange: (val: (typeof ANALYTICS_DURATION_FILTER_OPTIONS)[number]["value"]) => void; + //optional + button?: ReactNode; + dropdownArrow?: boolean; + dropdownArrowClassName?: string; + onClose?: () => void; + renderByDefault?: boolean; + tabIndex?: number; +}; + +function DurationDropdown({ placeholder = "Duration", onChange, value }: Props) { + useTranslation(); + + const options = ANALYTICS_DURATION_FILTER_OPTIONS.map((option) => ({ + value: option.value, + query: option.name, + content: ( +
+ {option.name} +
+ ), + })); + return ( + + + {value ? ANALYTICS_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name : placeholder} +
+ } + /> + ); +} + +export default DurationDropdown; diff --git a/apps/web/core/components/analytics/select/project.tsx b/apps/web/core/components/analytics/select/project.tsx new file mode 100644 index 0000000..cc0896c --- /dev/null +++ b/apps/web/core/components/analytics/select/project.tsx @@ -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 { observer } from "mobx-react"; +// plane package imports +import { getButtonStyling } from "@plane/propel/button"; +import { Logo } from "@plane/propel/emoji-icon-picker"; +import { ChevronDownOutline, ProjectsOutline } from "@makeplane/propel/icons"; +import { CustomSearchSelect } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useProject } from "@/hooks/store/use-project"; + +type Props = { + value: string[] | undefined; + onChange: (val: string[] | null) => void; + projectIds: string[] | undefined; +}; + +export const ProjectSelect = observer(function ProjectSelect(props: Props) { + const { value, onChange, projectIds } = props; + const { getProjectById } = useProject(); + + const options = projectIds?.map((projectId) => { + const projectDetails = getProjectById(projectId); + + return { + value: projectDetails?.id, + query: `${projectDetails?.name} ${projectDetails?.identifier}`, + content: ( +
+ {projectDetails?.logo_props ? ( + + ) : ( + + )} + {projectDetails?.name} +
+ ), + }; + }); + + return ( + onChange(val)} + options={options} + className="border-none p-0" + customButton={ +
+ + {value && value.length > 3 + ? `3+ projects` + : value && value.length > 0 + ? projectIds + ?.filter((p) => value.includes(p)) + .map((p) => getProjectById(p)?.name) + .join(", ") + : "All projects"} +
+ } + customButtonClassName="border-none p-0 bg-transparent hover:bg-transparent w-auto h-auto" + multiple + /> + ); +}); diff --git a/apps/web/core/components/analytics/select/select-x-axis.tsx b/apps/web/core/components/analytics/select/select-x-axis.tsx new file mode 100644 index 0000000..6effcfa --- /dev/null +++ b/apps/web/core/components/analytics/select/select-x-axis.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. + */ + +// plane package imports +import type { ChartXAxisProperty } from "@plane/types"; +import { CustomSelect } from "@plane/ui"; + +type Props = { + value?: ChartXAxisProperty; + onChange: (val: ChartXAxisProperty | null) => void; + options: { value: ChartXAxisProperty; label: string }[]; + placeholder?: string; + hiddenOptions?: ChartXAxisProperty[]; + allowNoValue?: boolean; + label?: string | React.ReactNode; +}; + +export function SelectXAxis(props: Props) { + const { value, onChange, options, hiddenOptions, allowNoValue, label } = props; + return ( + + {allowNoValue && No value} + {options.map((item) => { + if (hiddenOptions?.includes(item.value)) return null; + return ( + + {item.label} + + ); + })} + + ); +} diff --git a/apps/web/core/components/analytics/select/select-y-axis.tsx b/apps/web/core/components/analytics/select/select-y-axis.tsx new file mode 100644 index 0000000..0d0470e --- /dev/null +++ b/apps/web/core/components/analytics/select/select-y-axis.tsx @@ -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 { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { EEstimateSystem } from "@plane/constants"; +import { ProjectsOutline } from "@makeplane/propel/icons"; +import type { ChartYAxisMetric } from "@plane/types"; +// plane package imports +import { CustomSelect } from "@plane/ui"; +// hooks +import { useProjectEstimates } from "@/hooks/store/estimates"; +// plane web constants +type Props = { + value: ChartYAxisMetric; + onChange: (val: ChartYAxisMetric | null) => void; + hiddenOptions?: ChartYAxisMetric[]; + options: { value: ChartYAxisMetric; label: string }[]; +}; + +export const SelectYAxis = observer(function SelectYAxis({ value, onChange, hiddenOptions, options }: Props) { + // hooks + const { projectId } = useParams(); + const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates(); + + const isEstimateEnabled = (analyticsOption: string) => { + if (analyticsOption === "estimate") { + if ( + projectId && + currentActiveEstimateId && + areEstimateEnabledByProjectId(projectId.toString()) && + estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS + ) { + return true; + } else { + return false; + } + } + + return true; + }; + + return ( + + + {options.find((v) => v.value === value)?.label ?? "Add Metric"} +
+ } + onChange={onChange} + maxHeight="lg" + > + {options.map((item) => { + if (hiddenOptions?.includes(item.value)) return null; + return ( + isEstimateEnabled(item.value) && ( + + {item.label} + + ) + ); + })} + + ); +}); diff --git a/apps/web/core/components/analytics/tabs.tsx b/apps/web/core/components/analytics/tabs.tsx new file mode 100644 index 0000000..1fdbc0c --- /dev/null +++ b/apps/web/core/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/apps/web/core/components/analytics/total-insights.tsx b/apps/web/core/components/analytics/total-insights.tsx new file mode 100644 index 0000000..98e5ed0 --- /dev/null +++ b/apps/web/core/components/analytics/total-insights.tsx @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// plane package imports +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +import type { IInsightField } from "@plane/constants"; +import { ANALYTICS_INSIGHTS_FIELDS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { IAnalyticsResponse, TAnalyticsTabsBase } from "@plane/types"; +import { cn } from "@plane/utils"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +// services +import { AnalyticsService } from "@/services/analytics.service"; +// local imports +import InsightCard from "./insight-card"; + +const analyticsService = new AnalyticsService(); + +const getInsightLabel = ( + analyticsType: TAnalyticsTabsBase, + item: IInsightField, + isEpic: boolean | undefined, + t: (key: string, params?: Record) => string +) => { + if (analyticsType === "work-items") { + return isEpic + ? t(item.i18nKey, { entity: t("common.epics") }) + : t(item.i18nKey, { entity: t("common.work_items") }); + } + + // Get the base translation with entity + const baseTranslation = t(item.i18nKey, { + ...item.i18nProps, + entity: item.i18nProps?.entity && t(item.i18nProps?.entity), + }); + + // Add prefix if available + const prefix = item.i18nProps?.prefix ? `${t(item.i18nProps.prefix)} ` : ""; + + // Add suffix if available + const suffix = item.i18nProps?.suffix ? ` ${t(item.i18nProps.suffix)}` : ""; + + // Combine prefix, base translation, and suffix + return `${prefix}${baseTranslation}${suffix}`; +}; + +const TotalInsights = observer(function TotalInsights({ + analyticsType, + peekView, +}: { + analyticsType: TAnalyticsTabsBase; + peekView?: boolean; +}) { + const params = useParams(); + const workspaceSlug = params.workspaceSlug.toString(); + const { t } = useTranslation(); + const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics(); + const { data: totalInsightsData, isLoading } = useSWR( + `total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isEpic}`, + () => + analyticsService.getAdvanceAnalytics( + workspaceSlug, + analyticsType, + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isEpic ? { epic: true } : {}), + }, + isPeekView + ) + ); + return ( +
+ {ANALYTICS_INSIGHTS_FIELDS[analyticsType]?.map((item) => ( + + ))} +
+ ); +}); + +export default TotalInsights; diff --git a/apps/web/core/components/analytics/trend-piece.tsx b/apps/web/core/components/analytics/trend-piece.tsx new file mode 100644 index 0000000..744e40c --- /dev/null +++ b/apps/web/core/components/analytics/trend-piece.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. + */ + +// plane package imports +import React from "react"; +import { TrendingDown, TrendingUp } from "lucide-react"; +import { cn } from "@plane/utils"; +// plane web components + +type Props = { + percentage: number; + className?: string; + size?: "xs" | "sm" | "md" | "lg"; + trendIconVisible?: boolean; + variant?: "simple" | "outlined" | "tinted"; +}; + +const sizeConfig = { + xs: { + text: "text-11", + icon: "w-3 h-3", + }, + sm: { + text: "text-13", + icon: "w-4 h-4", + }, + md: { + text: "text-14", + icon: "w-5 h-5", + }, + lg: { + text: "text-16", + icon: "w-6 h-6", + }, +} as const; + +const variants: Record, Record<"ontrack" | "offtrack" | "atrisk", string>> = { + simple: { + ontrack: "text-success-primary", + offtrack: "text-yellow-500", + atrisk: "text-danger-primary", + }, + outlined: { + ontrack: "text-success-primary border border-success-strong", + offtrack: "text-yellow-500 border border-yellow-500", + atrisk: "text-danger-primary border border-danger-strong", + }, + tinted: { + ontrack: "text-success-primary bg-success-subtle", + offtrack: "text-yellow-500 bg-yellow-500/10", + atrisk: "text-danger-primary bg-danger-subtle", + }, +} as const; + +function TrendPiece(props: Props) { + const { percentage, className, trendIconVisible = true, size = "sm", variant = "simple" } = props; + const isOnTrack = percentage >= 66; + const isOffTrack = percentage >= 33 && percentage < 66; + const config = sizeConfig[size]; + + return ( +
+ {trendIconVisible && + (isOnTrack ? ( + + ) : isOffTrack ? ( + + ) : ( + + ))} + {Math.round(Math.abs(percentage))}% +
+ ); +} + +export default TrendPiece; diff --git a/apps/web/core/components/analytics/use-analytics-tabs.tsx b/apps/web/core/components/analytics/use-analytics-tabs.tsx new file mode 100644 index 0000000..d022d74 --- /dev/null +++ b/apps/web/core/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/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx b/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx new file mode 100644 index 0000000..c4723c0 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx @@ -0,0 +1,139 @@ +/** + * 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 { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane package imports +import { useTranslation } from "@plane/i18n"; +import { AreaChart } from "@plane/propel/charts/area-chart"; +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import type { IChartResponse, TChartData } from "@plane/types"; +import { renderFormattedDate } from "@plane/utils"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +// services +import { AnalyticsService } from "@/services/analytics.service"; +// plane web components +import AnalyticsSectionWrapper from "../analytics-section-wrapper"; +import { ChartLoader } from "../loaders"; + +const analyticsService = new AnalyticsService(); +const CreatedVsResolved = observer(function CreatedVsResolved() { + const { + selectedDuration, + selectedDurationLabel, + selectedProjects, + selectedCycle, + selectedModule, + isPeekView, + isEpic, + } = useAnalytics(); + const params = useParams(); + const { t } = useTranslation(); + const workspaceSlug = params.workspaceSlug.toString(); + const { data: createdVsResolvedData, isLoading: isCreatedVsResolvedLoading } = useSWR( + `created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}-${isEpic}`, + () => + analyticsService.getAdvanceAnalyticsCharts( + workspaceSlug, + "work-items", + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isEpic ? { epic: true } : {}), + }, + isPeekView + ) + ); + const parsedData: TChartData[] = useMemo(() => { + if (!createdVsResolvedData?.data) return []; + return createdVsResolvedData.data.map((datum) => ({ + ...datum, + [datum.key]: datum.count, + name: renderFormattedDate(datum.key) ?? datum.key, + })); + }, [createdVsResolvedData]); + + const areas = useMemo( + () => [ + { + key: "completed_issues", + label: "Resolved", + fill: "#19803833", + fillOpacity: 1, + stackId: "bar-one", + showDot: false, + smoothCurves: true, + strokeColor: "#198038", + strokeOpacity: 1, + }, + { + key: "created_issues", + label: "Created", + fill: "#1192E833", + fillOpacity: 1, + stackId: "bar-one", + showDot: false, + smoothCurves: true, + strokeColor: "#1192E8", + strokeOpacity: 1, + }, + ], + [] + ); + + return ( + + {isCreatedVsResolvedLoading ? ( + + ) : parsedData && parsedData.length > 0 ? ( + + ) : ( + + )} + + ); +}); + +export default CreatedVsResolved; diff --git a/apps/web/core/components/analytics/work-items/customized-insights.tsx b/apps/web/core/components/analytics/work-items/customized-insights.tsx new file mode 100644 index 0000000..c40a853 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/customized-insights.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 { useParams } from "next/navigation"; +import { useForm } from "react-hook-form"; +// plane package imports +import { useTranslation } from "@plane/i18n"; +import type { IAnalyticsParams } from "@plane/types"; +import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/types"; +import { cn } from "@plane/utils"; +// plane web components +import AnalyticsSectionWrapper from "../analytics-section-wrapper"; +import { AnalyticsSelectParams } from "../select/analytics-params"; +import PriorityChart from "./priority-chart"; + +const CustomizedInsights = observer(function CustomizedInsights({ + peekView, + isEpic, +}: { + peekView?: boolean; + isEpic?: boolean; +}) { + const { t } = useTranslation(); + const { workspaceSlug } = useParams(); + const { control, watch, setValue } = useForm({ + defaultValues: { + x_axis: ChartXAxisProperty.PRIORITY, + y_axis: isEpic ? ChartYAxisMetric.EPIC_WORK_ITEM_COUNT : ChartYAxisMetric.WORK_ITEM_COUNT, + }, + }); + + const params = { + x_axis: watch("x_axis"), + y_axis: watch("y_axis"), + group_by: watch("group_by"), + }; + + return ( + + } + > + + + ); +}); + +export default CustomizedInsights; diff --git a/apps/web/core/components/analytics/work-items/index.ts b/apps/web/core/components/analytics/work-items/index.ts new file mode 100644 index 0000000..d980334 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/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/apps/web/core/components/analytics/work-items/modal/content.tsx b/apps/web/core/components/analytics/work-items/modal/content.tsx new file mode 100644 index 0000000..259dec2 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/modal/content.tsx @@ -0,0 +1,87 @@ +/** + * 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 { observer } from "mobx-react"; +import { Tab } from "@headlessui/react"; +// plane package imports +import type { ICycle, IModule, IProject } from "@plane/types"; +import { Spinner } from "@plane/ui"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +// plane web components +import TotalInsights from "../../total-insights"; +import CreatedVsResolved from "../created-vs-resolved"; +import CustomizedInsights from "../customized-insights"; +import WorkItemsInsightTable from "../workitems-insight-table"; + +type Props = { + fullScreen: boolean; + projectDetails: IProject | undefined; + cycleDetails: ICycle | undefined; + moduleDetails: IModule | undefined; + isEpic?: boolean; +}; + +export const WorkItemsModalMainContent = observer(function WorkItemsModalMainContent(props: Props) { + const { projectDetails, cycleDetails, moduleDetails, fullScreen, isEpic } = props; + const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalytics(); + const [isModalConfigured, setIsModalConfigured] = useState(false); + + useEffect(() => { + updateIsPeekView(true); + + // Handle project selection + if (projectDetails?.id) { + updateSelectedProjects([projectDetails.id]); + } + + // Handle cycle selection + if (cycleDetails?.id) { + updateSelectedCycle(cycleDetails.id); + } + + // Handle module selection + if (moduleDetails?.id) { + updateSelectedModule(moduleDetails.id); + } + setIsModalConfigured(true); + + // Cleanup fields + return () => { + updateSelectedProjects([]); + updateSelectedCycle(""); + updateSelectedModule(""); + updateIsPeekView(false); + }; + }, [ + projectDetails?.id, + cycleDetails?.id, + moduleDetails?.id, + updateSelectedProjects, + updateSelectedCycle, + updateSelectedModule, + updateIsPeekView, + ]); + + if (!isModalConfigured) + return ( +
+ +
+ ); + + return ( + +
+ + + + +
+
+ ); +}); diff --git a/apps/web/core/components/analytics/work-items/modal/header.tsx b/apps/web/core/components/analytics/work-items/modal/header.tsx new file mode 100644 index 0000000..9270452 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/modal/header.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"; +// plane package imports +import { ArrowCollapseOutline, CloseOutline, FullScreenOutline } from "@makeplane/propel/icons"; +import type { ICycle, IModule } from "@plane/types"; +// icons + +type Props = { + fullScreen: boolean; + handleClose: () => void; + setFullScreen: React.Dispatch>; + title: string; + cycle?: ICycle; + module?: IModule; +}; + +export const WorkItemsModalHeader = observer(function WorkItemsModalHeader(props: Props) { + const { fullScreen, handleClose, setFullScreen, title, cycle, module } = props; + + return ( +
+

+ Analytics for {title} {cycle && `in ${cycle.name}`} {module && `in ${module.name}`} +

+
+ + +
+
+ ); +}); diff --git a/apps/web/core/components/analytics/work-items/modal/index.tsx b/apps/web/core/components/analytics/work-items/modal/index.tsx new file mode 100644 index 0000000..a284ba9 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/modal/index.tsx @@ -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 React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +// plane package imports +import { ModalPortal, EPortalWidth, EPortalPosition } from "@plane/propel/portal"; +import type { ICycle, IModule, IProject } from "@plane/types"; +import { useAnalytics } from "@/hooks/store/use-analytics"; +// plane web components +import { WorkItemsModalMainContent } from "./content"; +import { WorkItemsModalHeader } from "./header"; + +type Props = { + isOpen: boolean; + onClose: () => void; + projectDetails?: IProject | undefined; + cycleDetails?: ICycle | undefined; + moduleDetails?: IModule | undefined; + isEpic?: boolean; +}; + +export const WorkItemsModal = observer(function WorkItemsModal(props: Props) { + const { isOpen, onClose, projectDetails, moduleDetails, cycleDetails, isEpic } = props; + const { updateIsEpic, isPeekView } = useAnalytics(); + const [fullScreen, setFullScreen] = useState(false); + + const handleClose = () => { + setFullScreen(false); + onClose(); + }; + + useEffect(() => { + updateIsEpic(isPeekView ? (isEpic ?? false) : false); + }, [isEpic, updateIsEpic, isPeekView]); + + return ( + +
+ + +
+
+ ); +}); diff --git a/apps/web/core/components/analytics/work-items/priority-chart.tsx b/apps/web/core/components/analytics/work-items/priority-chart.tsx new file mode 100644 index 0000000..97adc63 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/priority-chart.tsx @@ -0,0 +1,251 @@ +/** + * 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 type { ColumnDef, Row, RowData, Table } from "@tanstack/react-table"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +// plane package imports +import { DownloadOutline } from "@makeplane/propel/icons"; +import type { ChartXAxisDateGrouping } from "@plane/constants"; +import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, CHART_COLOR_PALETTES, EChartModels } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { BarChart } from "@plane/propel/charts/bar-chart"; +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import type { TBarItem, TChart, TChartDatum, ChartXAxisProperty, ChartYAxisMetric } from "@plane/types"; +// plane web components +import { generateExtendedColors, parseChartData } from "@/components/chart/utils"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +import { useProjectState } from "@/hooks/store/use-project-state"; +import { AnalyticsService } from "@/services/analytics.service"; +import { exportCSV } from "../export"; +import { DataTable } from "../insight-table/data-table"; +import { ChartLoader } from "../loaders"; +import { generateBarColor } from "./utils"; + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + export: { + key: string; + value: (row: Row) => string | number; + label?: string; + }; + } +} + +interface Props { + x_axis: ChartXAxisProperty; + y_axis: ChartYAxisMetric; + group_by?: ChartXAxisProperty; + x_axis_date_grouping?: ChartXAxisDateGrouping; +} + +const analyticsService = new AnalyticsService(); +const PriorityChart = observer(function PriorityChart(props: Props) { + const { x_axis, y_axis, group_by } = props; + const { t } = useTranslation(); + // store hooks + const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics(); + const { workspaceStates } = useProjectState(); + const { resolvedTheme } = useTheme(); + // router + const params = useParams(); + const workspaceSlug = params.workspaceSlug.toString(); + + const { data: priorityChartData, isLoading: priorityChartLoading } = useSWR( + `customized-insights-chart-${workspaceSlug}-${selectedDuration}- + ${selectedProjects}-${selectedCycle}-${selectedModule}-${props.x_axis}-${props.y_axis}-${props.group_by}-${isPeekView}-${isEpic}`, + () => + analyticsService.getAdvanceAnalyticsCharts( + workspaceSlug, + "custom-work-items", + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isEpic ? { epic: true } : {}), + ...props, + }, + isPeekView + ) + ); + const parsedData = useMemo( + () => + priorityChartData && parseChartData(priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping), + [priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping] + ); + const chart_model = props.group_by ? EChartModels.STACKED : EChartModels.BASIC; + + const bars: TBarItem[] = useMemo(() => { + if (!parsedData) return []; + let parsedBars: TBarItem[]; + const schemaKeys = Object.keys(parsedData.schema); + const baseColors = CHART_COLOR_PALETTES[0]?.[resolvedTheme === "dark" ? "dark" : "light"]; + const extendedColors = generateExtendedColors(baseColors ?? [], schemaKeys.length); + if (chart_model === EChartModels.BASIC) { + parsedBars = [ + { + key: "count", + label: "Count", + stackId: "bar-one", + fill: (payload) => generateBarColor(payload.key, { x_axis, y_axis, group_by }, baseColors, workspaceStates), + textClassName: "", + showPercentage: false, + showTopBorderRadius: () => true, + showBottomBorderRadius: () => true, + }, + ]; + } else if (chart_model === EChartModels.STACKED && parsedData.schema) { + const parsedExtremes: { + [key: string]: { + top: string | null; + bottom: string | null; + }; + } = {}; + parsedData.data.forEach((datum) => { + let top = null; + let bottom = null; + for (let i = 0; i < schemaKeys.length; i++) { + const key = schemaKeys[i]; + if (datum[key] === 0) continue; + if (!bottom) bottom = key; + top = key; + } + parsedExtremes[datum.key] = { top, bottom }; + }); + + parsedBars = schemaKeys.map((key, index) => ({ + key: key, + label: parsedData.schema[key], + stackId: "bar-one", + fill: extendedColors[index], + textClassName: "", + showPercentage: false, + showTopBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].top === value, + showBottomBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].bottom === value, + })); + } else { + parsedBars = []; + } + return parsedBars; + }, [chart_model, group_by, parsedData, resolvedTheme, workspaceStates, x_axis, y_axis]); + + const yAxisLabel = useMemo( + () => ANALYTICS_Y_AXIS_VALUES.find((item) => item.value === props.y_axis)?.label ?? props.y_axis, + [props.y_axis] + ); + const xAxisLabel = useMemo( + () => ANALYTICS_X_AXIS_VALUES.find((item) => item.value === props.x_axis)?.label ?? props.x_axis, + [props.x_axis] + ); + + const defaultColumns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "name", + header: () => xAxisLabel, + meta: { + export: { + key: xAxisLabel, + value: (row) => row.original.name, + label: xAxisLabel, + }, + }, + }, + { + accessorKey: "count", + header: () =>
Count
, + cell: ({ row }) =>
{row.original.count}
, + meta: { + export: { + key: "Count", + value: (row) => row.original.count, + label: "Count", + }, + }, + }, + ], + [xAxisLabel] + ); + + const columns: ColumnDef[] = useMemo( + () => + parsedData + ? Object.keys(parsedData?.schema ?? {}).map((key) => ({ + accessorKey: key, + header: () =>
{parsedData.schema[key]}
, + cell: ({ row }) =>
{row.original[key]}
, + meta: { + export: { + key, + value: (row) => row.original[key], + label: parsedData.schema[key], + }, + }, + })) + : [], + [parsedData] + ); + + return ( +
+ {priorityChartLoading ? ( + + ) : parsedData?.data && parsedData.data.length > 0 ? ( + <> + + ) => ( + + )} + /> + + ) : ( + + )} +
+ ); +}); + +export default PriorityChart; diff --git a/apps/web/core/components/analytics/work-items/root.tsx b/apps/web/core/components/analytics/work-items/root.tsx new file mode 100644 index 0000000..55fb767 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/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 AnalyticsWrapper from "../analytics-wrapper"; +import TotalInsights from "../total-insights"; +import CreatedVsResolved from "./created-vs-resolved"; +import CustomizedInsights from "./customized-insights"; +import WorkItemsInsightTable from "./workitems-insight-table"; + +function WorkItems() { + return ( + +
+ + + + +
+
+ ); +} + +export { WorkItems }; diff --git a/apps/web/core/components/analytics/work-items/utils.ts b/apps/web/core/components/analytics/work-items/utils.ts new file mode 100644 index 0000000..38535a1 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/utils.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. + */ + +// plane package imports +import type { ChartYAxisMetric, IState } from "@plane/types"; +import { ChartXAxisProperty } from "@plane/types"; + +interface ParamsProps { + x_axis: ChartXAxisProperty; + y_axis: ChartYAxisMetric; + group_by?: ChartXAxisProperty; +} + +export const generateBarColor = ( + value: string | null | undefined, + params: ParamsProps, + baseColors: string[], + workspaceStates?: IState[] +): string => { + if (!value) return baseColors[0]; + let color = baseColors[0]; + // Priority + if (params.x_axis === ChartXAxisProperty.PRIORITY) { + color = + value === "urgent" + ? "#ef4444" + : value === "high" + ? "#f97316" + : value === "medium" + ? "#eab308" + : value === "low" + ? "#22c55e" + : "#ced4da"; + } + + // State + if (params.x_axis === ChartXAxisProperty.STATES) { + if (workspaceStates && workspaceStates.length > 0) { + const state = workspaceStates.find((s) => s.id === value); + if (state) { + color = state.color; + } else { + const index = Math.abs(value.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % baseColors.length; + color = baseColors[index]; + } + } + } + + return color; +}; diff --git a/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx b/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx new file mode 100644 index 0000000..60dbef7 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx @@ -0,0 +1,210 @@ +/** + * 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 type { ColumnDef, Row, RowData } from "@tanstack/react-table"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +import { ProjectsOutline, UserOutline } from "@makeplane/propel/icons"; +import { Avatar } from "@makeplane/propel/components/avatar"; +import { useTranslation } from "@plane/i18n"; +import { Logo } from "@plane/propel/emoji-icon-picker"; +// plane package imports +import type { AnalyticsTableDataMap, WorkItemInsightColumns } from "@plane/types"; +// plane web components + +import { getFileURL } from "@plane/utils"; +// hooks +import { useAnalytics } from "@/hooks/store/use-analytics"; +import { useProject } from "@/hooks/store/use-project"; +import { AnalyticsService } from "@/services/analytics.service"; +// plane web components +import { exportCSV } from "../export"; +import { InsightTable } from "../insight-table"; + +const analyticsService = new AnalyticsService(); + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + export: { + key: string; + value: (row: Row) => string | number; + label?: string; + }; + } +} + +const WorkItemsInsightTable = observer(function WorkItemsInsightTable() { + // router + const params = useParams(); + const workspaceSlug = params.workspaceSlug.toString(); + const { t } = useTranslation(); + // store hooks + const { getProjectById } = useProject(); + const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics(); + const { data: workItemsData, isLoading } = useSWR( + `insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}-${isEpic}`, + () => + analyticsService.getAdvanceAnalyticsStats( + workspaceSlug, + "work-items", + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isEpic ? { epic: true } : {}), + }, + isPeekView + ) + ); + // derived values + const columnsLabels: Record, string> = + useMemo( + () => ({ + backlog_work_items: t("workspace_projects.state.backlog"), + started_work_items: t("workspace_projects.state.started"), + un_started_work_items: t("workspace_projects.state.unstarted"), + completed_work_items: t("workspace_projects.state.completed"), + cancelled_work_items: t("workspace_projects.state.cancelled"), + project__name: t("common.project"), + display_name: t("common.assignee"), + }), + [t] + ); + const columns: ColumnDef[] = useMemo( + () => [ + !isPeekView + ? { + accessorKey: "project__name", + header: () =>
{columnsLabels["project__name"]}
, + cell: ({ row }) => { + const project = getProjectById(row.original.project_id); + return ( +
+ {project?.logo_props ? ( + + ) : ( + + )} + {project?.name} +
+ ); + }, + meta: { + export: { + key: columnsLabels["project__name"], + value: (row) => row.original.project__name?.toString() ?? "", + }, + }, + } + : { + accessorKey: "display_name", + header: () =>
{columnsLabels["display_name"]}
, + cell: ({ row }: { row: Row }) => ( +
+
+ {row.original.avatar_url && row.original.avatar_url !== "" ? ( + + ) : ( +
+ {row.original.display_name ? ( + row.original.display_name?.[0] + ) : ( + + )} +
+ )} + {row.original.display_name ?? t(`Unassigned`)} +
+
+ ), + meta: { + export: { + key: columnsLabels["display_name"], + value: (row) => row.original.display_name?.toString() ?? "", + }, + }, + }, + { + accessorKey: "backlog_work_items", + header: () =>
{columnsLabels["backlog_work_items"]}
, + cell: ({ row }) =>
{row.original.backlog_work_items}
, + meta: { + export: { + key: columnsLabels["backlog_work_items"], + value: (row) => row.original.backlog_work_items.toString(), + }, + }, + }, + { + accessorKey: "started_work_items", + header: () =>
{columnsLabels["started_work_items"]}
, + cell: ({ row }) =>
{row.original.started_work_items}
, + meta: { + export: { + key: columnsLabels["started_work_items"], + value: (row) => row.original.started_work_items.toString(), + }, + }, + }, + { + accessorKey: "un_started_work_items", + header: () =>
{columnsLabels["un_started_work_items"]}
, + cell: ({ row }) =>
{row.original.un_started_work_items}
, + meta: { + export: { + key: columnsLabels["un_started_work_items"], + value: (row) => row.original.un_started_work_items.toString(), + }, + }, + }, + { + accessorKey: "completed_work_items", + header: () =>
{columnsLabels["completed_work_items"]}
, + cell: ({ row }) =>
{row.original.completed_work_items}
, + meta: { + export: { + key: columnsLabels["completed_work_items"], + value: (row) => row.original.completed_work_items.toString(), + }, + }, + }, + { + accessorKey: "cancelled_work_items", + header: () =>
{columnsLabels["cancelled_work_items"]}
, + cell: ({ row }) =>
{row.original.cancelled_work_items}
, + meta: { + export: { + key: columnsLabels["cancelled_work_items"], + value: (row) => row.original.cancelled_work_items.toString(), + }, + }, + }, + ], + [columnsLabels, getProjectById, isPeekView, t] + ); + return ( + + analyticsType="work-items" + data={workItemsData} + isLoading={isLoading} + columns={columns} + columnsLabels={columnsLabels} + headerText={isPeekView ? t("common.assignee") : t("common.projects")} + onExport={(rows) => workItemsData && exportCSV(rows, columns, workspaceSlug)} + /> + ); +}); + +export default WorkItemsInsightTable; diff --git a/apps/web/core/components/api-token/delete-token-modal.tsx b/apps/web/core/components/api-token/delete-token-modal.tsx new file mode 100644 index 0000000..58a9339 --- /dev/null +++ b/apps/web/core/components/api-token/delete-token-modal.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 { useState } from "react"; +import { mutate } from "swr"; +// types +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { APITokenService } from "@plane/services"; +import type { IApiToken } from "@plane/types"; +// ui +import { AlertModalCore } from "@plane/ui"; +// fetch-keys +import { API_TOKENS_LIST } from "@plane/constants"; + +type Props = { + isOpen: boolean; + onClose: () => void; + tokenId: string; +}; + +const apiTokenService = new APITokenService(); + +export function DeleteApiTokenModal(props: Props) { + const { isOpen, onClose, tokenId } = props; + // states + const [deleteLoading, setDeleteLoading] = useState(false); + // router params + const { t } = useTranslation(); + + const handleClose = () => { + onClose(); + setDeleteLoading(false); + }; + + const handleDeletion = async () => { + setDeleteLoading(true); + + await apiTokenService + .destroy(tokenId) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("workspace_settings.settings.api_tokens.delete.success.title"), + message: t("workspace_settings.settings.api_tokens.delete.success.message"), + }); + + mutate( + API_TOKENS_LIST, + (prevData) => (prevData ?? []).filter((token) => token.id !== tokenId), + false + ); + + handleClose(); + setDeleteLoading(false); + }) + .catch((err) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("workspace_settings.settings.api_tokens.delete.error.title"), + message: err?.message ?? t("workspace_settings.settings.api_tokens.delete.error.message"), + }); + setDeleteLoading(false); + }); + }; + + return ( + {t("workspace_settings.settings.api_tokens.delete.description")} } + /> + ); +} diff --git a/apps/web/core/components/api-token/empty-state.tsx b/apps/web/core/components/api-token/empty-state.tsx new file mode 100644 index 0000000..944d19e --- /dev/null +++ b/apps/web/core/components/api-token/empty-state.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 React from "react"; +// ui +import { Button } from "@plane/propel/button"; +// assets +import emptyApiTokens from "@/app/assets/empty-state/api-token.svg?url"; + +type Props = { + onClick: () => void; +}; + +export function ApiTokenEmptyState(props: Props) { + const { onClick } = props; + + return ( +
+
+ empty +
No API tokens
+

+ Create API tokens for safe and easy data sharing with external apps, maintaining control and security. +

+ +
+
+ ); +} diff --git a/apps/web/core/components/api-token/modal/create-token-modal.tsx b/apps/web/core/components/api-token/modal/create-token-modal.tsx new file mode 100644 index 0000000..f9d4872 --- /dev/null +++ b/apps/web/core/components/api-token/modal/create-token-modal.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 { useState } from "react"; +import { mutate } from "swr"; +// plane imports +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { APITokenService } from "@plane/services"; +import type { IApiToken } from "@plane/types"; +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; +import { renderFormattedDate, csvDownload } from "@plane/utils"; +// constants +import { API_TOKENS_LIST } from "@plane/constants"; +// local imports +import { CreateApiTokenForm } from "./form"; +import { GeneratedTokenDetails } from "./generated-token-details"; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +// services +const apiTokenService = new APITokenService(); + +export function CreateApiTokenModal(props: Props) { + const { isOpen, onClose } = props; + // states + const [neverExpires, setNeverExpires] = useState(false); + const [generatedToken, setGeneratedToken] = useState(null); + + const handleClose = () => { + onClose(); + + setTimeout(() => { + setNeverExpires(false); + setGeneratedToken(null); + }, 350); + }; + + const downloadSecretKey = (data: IApiToken) => { + const csvData = { + Title: data.label, + Description: data.description, + Expiry: data.expired_at ? (renderFormattedDate(data.expired_at)?.replace(",", " ") ?? "") : "Never expires", + "Secret key": data.token ?? "", + }; + + csvDownload(csvData, `secret-key-${Date.now()}`); + }; + + const handleCreateToken = async (data: Partial) => { + // make the request to generate the token + await apiTokenService + .create(data) + .then((res) => { + setGeneratedToken(res); + downloadSecretKey(res); + + mutate( + API_TOKENS_LIST, + (prevData) => { + if (!prevData) return; + + return [res, ...prevData]; + }, + false + ); + }) + .catch((err) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err.message || err.detail, + }); + + throw err; + }); + }; + + return ( + {}} position={EModalPosition.TOP} width={EModalWidth.XXL}> + {generatedToken ? ( + + ) : ( + setNeverExpires((prevData) => !prevData)} + onSubmit={handleCreateToken} + /> + )} + + ); +} diff --git a/apps/web/core/components/api-token/modal/form.tsx b/apps/web/core/components/api-token/modal/form.tsx new file mode 100644 index 0000000..3cd9903 --- /dev/null +++ b/apps/web/core/components/api-token/modal/form.tsx @@ -0,0 +1,279 @@ +/** + * 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 { add } from "date-fns"; +import { Controller, useForm } from "react-hook-form"; +import { CalendarOutline } from "@makeplane/propel/icons"; +// types +import { Field } from "@makeplane/propel/components/field"; +import { Input, InputGroup } from "@makeplane/propel/components/input"; +import { TextArea, TextAreaGroup } from "@makeplane/propel/components/text-area"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IApiToken } from "@plane/types"; +// ui +import { Switch } from "@makeplane/propel/components/switch"; +import { CustomSelect } from "@plane/ui"; +import { cn, renderFormattedDate, renderFormattedTime } from "@plane/utils"; +// components +import { DateDropdown } from "@/components/dropdowns/date"; +// helpers +type Props = { + handleClose: () => void; + neverExpires: boolean; + toggleNeverExpires: () => void; + onSubmit: (data: Partial) => Promise; +}; + +const EXPIRY_DATE_OPTIONS = [ + { + key: "1_week", + label: "1 week", + value: { weeks: 1 }, + }, + { + key: "1_month", + label: "1 month", + value: { months: 1 }, + }, + { + key: "3_months", + label: "3 months", + value: { months: 3 }, + }, + { + key: "1_year", + label: "1 year", + value: { years: 1 }, + }, +]; + +const defaultValues: Partial = { + label: "", + description: "", + expired_at: null, +}; + +const getExpiryDate = (val: string): Date | null | undefined => { + const today = new Date(); + const dateToAdd = EXPIRY_DATE_OPTIONS.find((option) => option.key === val)?.value; + if (dateToAdd) return add(today, dateToAdd); + return null; +}; + +const getFormattedDate = (date: Date): Date => { + const now = new Date(); + const hours = now.getHours(); + const minutes = now.getMinutes(); + const seconds = now.getSeconds(); + return add(date, { hours, minutes, seconds }); +}; + +export function CreateApiTokenForm(props: Props) { + const { handleClose, neverExpires, toggleNeverExpires, onSubmit } = props; + // states + const [customDate, setCustomDate] = useState(null); + // form + const { + control, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + watch, + } = useForm({ defaultValues }); + // hooks + const { t } = useTranslation(); + + const handleFormSubmit = async (data: IApiToken) => { + // if never expires is toggled off, and the user has not selected a custom date or a predefined date, show an error + if (!neverExpires && (!data.expired_at || (data.expired_at === "custom" && !customDate))) + return setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Please select an expiration date.", + }); + + const payload: Partial = { + label: data.label, + description: data.description, + }; + + // if never expires is toggled on, set expired_at to null + if (neverExpires) payload.expired_at = null; + // if never expires is toggled off, and the user has selected a custom date, set expired_at to the custom date + else if (data.expired_at === "custom") { + payload.expired_at = customDate && getFormattedDate(customDate).toISOString(); + } + // if never expires is toggled off, and the user has selected a predefined date, set expired_at to the predefined date + else { + const expiryDate = getExpiryDate(data.expired_at ?? ""); + if (expiryDate) payload.expired_at = expiryDate.toISOString(); + } + + await onSubmit(payload).then(() => { + reset(defaultValues); + setCustomDate(null); + }); + }; + + const today = new Date(); + const tomorrow = add(today, { days: 1 }); + const expiredAt = watch("expired_at"); + const expiryDate = getExpiryDate(expiredAt ?? ""); + const customDateFormatted = customDate && getFormattedDate(customDate); + + return ( +
+
+

+ {t("workspace_settings.settings.api_tokens.create_token")} +

+
+
+ val.trim() !== "" || t("title_is_required"), + }} + render={({ field: { value, onChange } }) => ( + + + + + + )} + /> + {errors.label && {errors.label.message}} +
+ ( + + +